diff --git a/desktop/e2e/a11y.spec.ts b/desktop/e2e/a11y.spec.ts index 8b5e435..61cd683 100644 --- a/desktop/e2e/a11y.spec.ts +++ b/desktop/e2e/a11y.spec.ts @@ -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. diff --git a/desktop/e2e/backup.spec.ts b/desktop/e2e/backup.spec.ts index bcbd213..3f6ff49 100644 --- a/desktop/e2e/backup.spec.ts +++ b/desktop/e2e/backup.spec.ts @@ -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 () => { @@ -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); diff --git a/desktop/e2e/cache.spec.ts b/desktop/e2e/cache.spec.ts index bc3739a..9e7c131 100644 --- a/desktop/e2e/cache.spec.ts +++ b/desktop/e2e/cache.spec.ts @@ -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 } @@ -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`); @@ -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(); @@ -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) }); @@ -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" }); diff --git a/desktop/e2e/fixtures.ts b/desktop/e2e/fixtures.ts index f74a49a..4b2f9af 100644 --- a/desktop/e2e/fixtures.ts +++ b/desktop/e2e/fixtures.ts @@ -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"; @@ -67,3 +67,11 @@ export async function playFirstAlbum(page: Page): Promise { 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 { + 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); +} diff --git a/desktop/e2e/keyboard-a11y.spec.ts b/desktop/e2e/keyboard-a11y.spec.ts index fbb9462..ffa6c09 100644 --- a/desktop/e2e/keyboard-a11y.spec.ts +++ b/desktop/e2e/keyboard-a11y.spec.ts @@ -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(); diff --git a/desktop/e2e/lyrics.spec.ts b/desktop/e2e/lyrics.spec.ts index c154ac2..bdd9a4c 100644 --- a/desktop/e2e/lyrics.spec.ts +++ b/desktop/e2e/lyrics.spec.ts @@ -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 { const servers = await page.evaluate(async () => (await window.hocket.query({ type: "servers" })) as { type: string; data: { id: string }[] }); @@ -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. @@ -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); diff --git a/desktop/e2e/manual/real-server.spec.ts b/desktop/e2e/manual/real-server.spec.ts index 1f1f2c4..cc674ca 100644 --- a/desktop/e2e/manual/real-server.spec.ts +++ b/desktop/e2e/manual/real-server.spec.ts @@ -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"); diff --git a/desktop/e2e/motion-contrast.spec.ts b/desktop/e2e/motion-contrast.spec.ts index 6cf3e3c..51fcd13 100644 --- a/desktop/e2e/motion-contrast.spec.ts +++ b/desktop/e2e/motion-contrast.spec.ts @@ -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); @@ -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(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 }); @@ -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 }) => { diff --git a/desktop/e2e/native.spec.ts b/desktop/e2e/native.spec.ts index a9c9581..38588c7 100644 --- a/desktop/e2e/native.spec.ts +++ b/desktop/e2e/native.spec.ts @@ -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")); @@ -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. @@ -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 }); @@ -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 { diff --git a/desktop/e2e/playback.spec.ts b/desktop/e2e/playback.spec.ts index 40a0761..ed8d5fd 100644 --- a/desktop/e2e/playback.spec.ts +++ b/desktop/e2e/playback.spec.ts @@ -9,7 +9,7 @@ test.describe("playback and queue", () => { await expect(timeline).toBeVisible(); await expect(page.getByTestId("queue-row-current")).toHaveCount(1); await expect(page.getByTestId("queue-row-upcoming").first()).toBeVisible(); - await expect(timeline).toContainText("Continuing from"); + await expect(timeline).toContainText("Continue playing"); // Next moves the current item into history (nothing consumed). await page.getByTestId("next").click(); await expect(page.getByTestId("queue-row-history")).toHaveCount(1); @@ -28,6 +28,29 @@ test.describe("playback and queue", () => { await expect(pos).not.toHaveText("0:00", { timeout: 8000 }); }); + test("the queue rests on the current item with History above it, and each section clears", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + await page.getByTestId("next").click(); + await page.getByTestId("next").click(); + await expect(page.getByTestId("queue-row-history")).toHaveCount(2); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await fs.getByTestId("fs-mode-queue").click(); + // History is scrolled out of view above; the current item sits at the top. + await expect.poll(() => fs.getByTestId("queue-scroll").evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + const [listTop, currentTop] = await Promise.all([fs.getByTestId("queue-scroll").evaluate((el) => el.getBoundingClientRect().top), fs.getByTestId("queue-row-current").evaluate((el) => el.getBoundingClientRect().top)]); + expect(currentTop - listTop).toBeLessThan(80); + // The header toggles are the queue's shuffle / repeat / autoplay. + await fs.getByTestId("queue-repeat").click(); + await expect(fs.getByTestId("queue-repeat")).toHaveAttribute("aria-pressed", "true"); + await expect(fs.getByTestId("fs-repeat")).toHaveAttribute("aria-pressed", "true"); + await fs.getByTestId("queue-clear-history").click(); + await expect(page.getByTestId("queue-row-history")).toHaveCount(0); + await expect(fs.getByTestId("queue-row-current")).toHaveCount(1); + }); + test("the fullscreen player opens with F and shows lyrics, related and up next", async ({ hocket }) => { const { page } = hocket; await completeSetup(page); @@ -37,19 +60,51 @@ test.describe("playback and queue", () => { const fs = page.getByTestId("fullscreen-player"); await expect(fs).toBeVisible(); // It covers the whole window, sidebar and player bar included. - const box = (await fs.boundingBox())!; - const win = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })); - expect([Math.round(box.x), Math.round(box.y), Math.round(box.width), Math.round(box.height)]).toEqual([0, 0, win.w, win.h]); + // (Polled: the window itself may still be entering fullscreen.) + await expect.poll(async () => { + const box = (await fs.boundingBox())!; + const win = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })); + return [Math.round(box.x), Math.round(box.y), Math.round(box.width) - win.w, Math.round(box.height) - win.h]; + }).toEqual([0, 0, 0, 0]); // The play/pause glyph is visible against its round button. const colours = await fs.getByTestId("fs-play-pause").evaluate((el) => { const cs = getComputedStyle(el); return [cs.color, cs.backgroundColor]; }); expect(colours[0]).not.toBe(colours[1]); // The fluid background is fed the cover art, not the fallback gradient. const bg = fs.getByTestId("fluid-bg"); await expect(bg).toHaveAttribute("data-source", "artwork"); - await page.getByTestId("fs-tab-related").click(); - await expect(page.getByTestId("related-list")).toBeVisible(); - await page.getByTestId("fs-tab-upNext").click(); - await expect(page.getByTestId("fullscreen-player").getByTestId("queue-timeline")).toBeVisible(); + // Wide windows get the desktop layout: the controls column carries volume (the player bar is covered). + const vol = fs.getByTestId("fs-volume"); + await expect(vol).toBeVisible(); + const v0 = Number(await vol.inputValue()); + await vol.focus(); + await page.keyboard.press("ArrowLeft"); + await expect.poll(async () => Math.round(Number(await vol.inputValue()) * 100)).toBe(Math.round((v0 - 0.05) * 100)); + // It opens on the artwork; Lyrics / Queue / About replace it in place and the artwork shrinks next to the title. + await expect(fs.getByTestId("np-artwork")).toBeVisible(); + await expect(fs.getByTestId("np-source")).not.toHaveText(""); + await fs.getByTestId("fs-mode-queue").click(); + await expect(fs.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true"); + await expect(fs.getByTestId("np-artwork")).toHaveCount(0); + await expect(fs.getByTestId("np-thumb")).toBeVisible(); + await expect(fs.getByTestId("queue-timeline")).toBeVisible(); + await expect(fs.getByTestId("queue-header")).toBeVisible(); + await fs.getByTestId("fs-mode-about").click(); + await expect(fs.getByTestId("np-about")).toContainText("Duration"); + await expect(fs.getByTestId("related-list")).toBeVisible(); + // The chosen pane is remembered when the player closes and reopens. + await page.keyboard.press("Escape"); + await expect(fs).toHaveCount(0); + await page.getByTestId("toggle-fullscreen").click(); + await expect(fs.getByTestId("np-about")).toBeVisible(); + // Choosing it again (or the thumbnail) brings the artwork back. + await fs.getByTestId("fs-mode-about").click(); + await expect(fs.getByTestId("np-artwork")).toBeVisible(); + await expect(fs.getByTestId("fs-mode-about")).toHaveAttribute("aria-pressed", "false"); + // L and Q switch the stage while the player is open. + await page.keyboard.press("l"); + await expect(fs.getByTestId("np-pane-lyrics")).toBeVisible(); + await fs.getByTestId("np-thumb").click(); + await expect(fs.getByTestId("np-artwork")).toBeVisible(); await page.getByTestId("fullscreen-exit").click(); await expect(page.getByTestId("fullscreen-player")).toHaveCount(0); }); diff --git a/desktop/package.json b/desktop/package.json index 269eb9b..efce957 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -30,7 +30,6 @@ "@dnd-kit/utilities": "^3.2.2", "@kawarp/core": "^1.2.2", "@tanstack/react-virtual": "^3.13.12", - "lucide-react": "^1.47.0", "react": "^19.1.1", "react-dom": "^19.1.1", "zustand": "^5.0.8" @@ -38,6 +37,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@eslint/js": "^9.36.0", + "@iconify-json/material-symbols": "^1.2.93", "@napi-rs/cli": "^3.3.1", "@playwright/test": "^1.55.0", "@types/node": "^22.18.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 17b10af..b35ec7a 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.12 version: 3.14.13(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - lucide-react: - specifier: ^1.47.0 - version: 1.47.0(react@19.3.0) react: specifier: ^19.1.1 version: 19.3.0 @@ -45,9 +42,12 @@ importers: '@eslint/js': specifier: ^9.36.0 version: 9.39.5 + '@iconify-json/material-symbols': + specifier: ^1.2.93 + version: 1.2.93 '@napi-rs/cli': specifier: ^3.3.1 - version: 3.10.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@22.20.3) + version: 3.10.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.20.3) '@playwright/test': specifier: ^1.55.0 version: 1.63.0 @@ -648,6 +648,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/material-symbols@1.2.93': + resolution: {integrity: sha512-IfbpRixmEdeBrl2Bc+RtNZGb3NcTnMiPP4WausCParQYpIdezEYYbgMggJC1cYzm1q0f77p53t5tppy9PWsrvQ==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@inquirer/ansi@2.0.8': resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -2469,11 +2475,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lucide-react@1.47.0: - resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3701,6 +3702,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/material-symbols@1.2.93': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@inquirer/ansi@2.0.8': {} '@inquirer/checkbox@5.2.5(@types/node@22.20.3)': @@ -3858,7 +3865,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@napi-rs/cli@3.10.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@22.20.3)': + '@napi-rs/cli@3.10.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@22.20.3)': dependencies: '@inquirer/prompts': 8.7.2(@types/node@22.20.3) '@napi-rs/cross-toolchain': 1.0.3 @@ -3873,8 +3880,8 @@ snapshots: typanion: 3.14.0 typescript: 6.0.3 optionalDependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 transitivePeerDependencies: - '@napi-rs/cross-toolchain-arm64-target-aarch64' - '@napi-rs/cross-toolchain-arm64-target-armv7' @@ -5562,10 +5569,6 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@1.47.0(react@19.3.0): - dependencies: - react: 19.3.0 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 diff --git a/desktop/scripts/gen-icons.mjs b/desktop/scripts/gen-icons.mjs new file mode 100644 index 0000000..9f44676 --- /dev/null +++ b/desktop/scripts/gen-icons.mjs @@ -0,0 +1,81 @@ +// Generates src/renderer/components/material-icons.ts: the Material Symbols +// (rounded) glyphs the renderer uses, as SVG path bodies, from the Iconify +// dataset (@iconify-json/material-symbols, Apache-2.0). Only the icons listed +// here are emitted, so the app doesn't ship the whole set. +// +// node scripts/gen-icons.mjs +// +// Each renderer name maps to a Material Symbols name; for each we emit the +// outlined and the filled rounded variant (the same glyph when the symbol has +// only one). Re-run after changing the list; the output is committed. +import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const set = require("@iconify-json/material-symbols/icons.json"); + +/** Renderer name → Material Symbols name (underscores or dashes). */ +const MAP = { + play: "play_arrow", pause: "pause", stop: "stop", next: "skip_next", previous: "skip_previous", rewind: "fast_rewind", forward: "fast_forward", + shuffle: "shuffle", repeat: "repeat", repeatOne: "repeat_one", autoplay: "all_inclusive", volume: "volume_up", mute: "volume_off", + heart: "favorite", heartOff: "heart_minus", star: "star", starOff: "star", + queue: "queue_music", lyrics: "lyrics", fullscreen: "fullscreen", mini: "picture_in_picture_alt", devices: "devices", + search: "search", command: "keyboard_command_key", settings: "settings", + home: "home", library: "library_music", album: "album", artist: "person", playlist: "playlist_play", playlistAdd: "playlist_add", song: "music_note", genre: "category", + download: "download", downloadOff: "file_download_off", cached: "hard_drive", filter: "filter_alt", filterAdd: "library_add", stats: "insights", info: "info", + remove: "remove", trash: "delete", edit: "edit", close: "close", check: "check", + chevronDown: "expand_more", chevronRight: "chevron_right", chevronLeft: "chevron_left", chevronUp: "expand_less", + arrowDown: "arrow_downward", arrowUp: "arrow_upward", back: "arrow_back", forwardArrow: "arrow_forward", undo: "undo", redo: "redo", + playNext: "queue_play_next", playLater: "add_to_queue", pin: "push_pin", pinOff: "keep_off", restore: "history", seek: "arrow_forward", sleep: "bedtime", bug: "bug_report", + selectAll: "select_all", grip: "drag_indicator", dragHandle: "drag_handle", more: "more_horiz", moreVert: "more_vert", minimize: "minimize", maximize: "crop_square", restoreWin: "filter_none", + spinner: "progress_activity", offline: "wifi_off", warn: "warning", battery: "battery_saver", external: "open_in_new", folder: "folder", + copy: "content_copy", resume: "play_circle", music: "music_note", cloud: "cloud", lan: "lan", plus: "add", + // Settings sections, setup and stats (m3-pages). + tune: "tune", equalizer: "graphic_eq", transcode: "swap_horiz", connectCast: "cast", palette: "palette", keyboard: "keyboard", backup: "backup", customise: "dashboard_customize", headphones: "headphones", schedule: "schedule", dns: "dns", key: "key", +}; + +function body(name) { + let n = name; + for (let i = 0; i < 8; i++) { + const icon = set.icons[n]; + if (icon) return icon.body; + const alias = set.aliases?.[n]; + if (!alias) return undefined; + n = alias.parent; + } + return undefined; +} + +const first = (...names) => { + for (const n of names) { + const b = body(n); + if (b) return b; + } + return undefined; +}; + +const out = {}; +const missing = []; +for (const [local, material] of Object.entries(MAP)) { + const m = material.replace(/_/g, "-"); + const filled = first(`${m}-rounded`, m); + const outline = first(`${m}-outline-rounded`, `${m}-rounded`, `${m}-outline`, m); + if (!filled || !outline) missing.push(`${local} → ${material}`); + else out[local] = outline === filled ? [outline] : [outline, filled]; +} +if (missing.length) { + console.error(`missing Material Symbols: ${missing.join(", ")}`); + process.exit(1); +} + +const target = fileURLToPath(new URL("../src/renderer/components/material-icons.ts", import.meta.url)); +const lines = Object.entries(out).map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`); +writeFileSync(target, `// Generated by scripts/gen-icons.mjs from @iconify-json/material-symbols +// (Material Symbols, Apache-2.0, rounded). Do not edit; edit the script's map. +// Each entry: [outlined, filled?] SVG bodies for a 24×24 viewBox. +export const MATERIAL_ICONS: Record = { +${lines.join("\n")} +}; +`); +console.log(`[gen-icons] ${Object.keys(out).length} icons → ${target}`); diff --git a/desktop/src/renderer/components/AlbumGrid.tsx b/desktop/src/renderer/components/AlbumGrid.tsx index ca10275..ed1157c 100644 --- a/desktop/src/renderer/components/AlbumGrid.tsx +++ b/desktop/src/renderer/components/AlbumGrid.tsx @@ -138,7 +138,7 @@ export function AlbumGrid({ items, total, scope, targetKind, onOpen, onPlay, onN return (
- {total === 0 ?
{emptyMessage}
: null} + {total === 0 ?
{emptyMessage}
: null}
{vrows.map((vr) => (
@@ -171,7 +171,7 @@ export function AlbumGrid({ items, total, scope, targetKind, onOpen, onPlay, onN {it.subtitle ?
{it.subtitle}
: null} {it.badge ? : null} {/* Not a Tab stop: Enter opens, Shift+F10 has Play; the pointer gets the shortcut button. */} - {onPlay ? : null} + {onPlay ? : null}
); })} diff --git a/desktop/src/renderer/components/ContextMenu.tsx b/desktop/src/renderer/components/ContextMenu.tsx index 410cabf..c2668a3 100644 --- a/desktop/src/renderer/components/ContextMenu.tsx +++ b/desktop/src/renderer/components/ContextMenu.tsx @@ -65,7 +65,8 @@ export function ContextMenu() { useLayoutEffect(() => { if (!menu || !ref.current) return; - const r = ref.current.getBoundingClientRect(); + // Layout size, not getBoundingClientRect(): the menu opens scaled down by its entrance animation. + const r = { width: ref.current.offsetWidth, height: ref.current.offsetHeight }; const x = Math.min(menu.x, window.innerWidth - r.width - 6); const y = Math.min(menu.y, window.innerHeight - r.height - 6); setPos({ x: Math.max(4, x), y: Math.max(4, y) }); diff --git a/desktop/src/renderer/components/CredentialWarning.tsx b/desktop/src/renderer/components/CredentialWarning.tsx index 5120cc0..8833087 100644 --- a/desktop/src/renderer/components/CredentialWarning.tsx +++ b/desktop/src/renderer/components/CredentialWarning.tsx @@ -2,9 +2,10 @@ // in memory (no usable OS keyring): the user must know it won't be remembered. import { t } from "@shared/strings"; import { useApp } from "../store/app"; +import { Icon } from "./Icon"; export function CredentialWarning() { const storage = useApp((s) => s.meta?.credentialStorage); if (storage !== "volatile") return null; - return
{t("settings.credentialsVolatile")}
; + return
{t("settings.credentialsVolatile")}
; } diff --git a/desktop/src/renderer/components/EmptyState.tsx b/desktop/src/renderer/components/EmptyState.tsx index 7eda29c..f5cd03b 100644 --- a/desktop/src/renderer/components/EmptyState.tsx +++ b/desktop/src/renderer/components/EmptyState.tsx @@ -1,9 +1,11 @@ import type { ReactNode } from "react"; +import { Icon } from "./Icon"; -/** Every empty state names the one action that fills it. No illustrations. */ -export function EmptyState({ message, action, testId }: { message: string; action?: ReactNode; testId?: string }) { +/** Every empty state names the one action that fills it. No illustrations; a library view may add one glyph in a tonal shape. */ +export function EmptyState({ message, action, testId, icon }: { message: string; action?: ReactNode; testId?: string; icon?: string }) { return (
+ {icon ? : null}
{message}
{action}
diff --git a/desktop/src/renderer/components/Icon.tsx b/desktop/src/renderer/components/Icon.tsx index 8cd648d..be7fe8e 100644 --- a/desktop/src/renderer/components/Icon.tsx +++ b/desktop/src/renderer/components/Icon.tsx @@ -1,36 +1,17 @@ -// Icons come from lucide-react (ISC): one name → component map, keyed by the -// short names the renderer uses, plus the Material Symbols names the action -// registry emits in `ActionDescriptor.icon`. Nothing in the UI draws an icon -// from a unicode glyph; anything unknown renders as a music note so a new -// registry icon is visible (and `icon.test.ts` fails) rather than blank. +// Icons are Material Symbols (rounded; Apache-2.0), generated into +// material-icons.ts by scripts/gen-icons.mjs: one name → glyph map, keyed by +// the short names the renderer uses, plus the Material Symbols names the +// action registry emits in `ActionDescriptor.icon`. Each glyph has an +// outlined and a filled variant: outlined by default (transport glyphs are +// filled), `filled` picks explicitly (a loved heart, a set star). Nothing in +// the UI draws an icon from a unicode glyph; anything unknown renders as a +// music note so a new registry icon is visible (and `icon.test.ts` fails) +// rather than blank. import type { CSSProperties } from "react"; -import { - ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BatteryMedium, Bug, ChartColumn, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, - Cloud, CloudOff, Command, Copy, Disc3, Download, Ellipsis, ExternalLink, FastForward, Filter, Folder, GripVertical, HardDrive, Heart, HeartOff, - History, House, Infinity as InfinityIcon, Info, LayoutGrid, Library, ListEnd, ListFilterPlus, ListMusic, ListPlus, ListStart, LoaderCircle, - Maximize2, MicVocal, Minus, MonitorSmartphone, Moon, Music, Network, Pause, Pencil, PictureInPicture2, Pin, PinOff, Play, Plus, - Redo2, Repeat, Repeat1, Rewind, RotateCcw, Search, Settings, Shuffle, SkipBack, SkipForward, Square, SquareCheck, Star, StarOff, - Trash2, TriangleAlert, Undo2, User, Volume2, VolumeX, WifiOff, X, - type LucideIcon, -} from "lucide-react"; +import { MATERIAL_ICONS } from "./material-icons"; -/** Renderer icon names → lucide components. */ -const ICONS: Record = { - play: Play, pause: Pause, stop: Square, next: SkipForward, previous: SkipBack, rewind: Rewind, forward: FastForward, - shuffle: Shuffle, repeat: Repeat, repeatOne: Repeat1, autoplay: InfinityIcon, volume: Volume2, mute: VolumeX, - heart: Heart, heartOff: HeartOff, star: Star, starOff: StarOff, - queue: ListMusic, lyrics: MicVocal, fullscreen: Maximize2, mini: PictureInPicture2, devices: MonitorSmartphone, - search: Search, command: Command, settings: Settings, - home: House, library: Library, album: Disc3, artist: User, playlist: ListMusic, playlistAdd: ListPlus, song: Music, genre: LayoutGrid, - download: Download, downloadOff: CloudOff, cached: HardDrive, filter: Filter, filterAdd: ListFilterPlus, stats: ChartColumn, info: Info, - remove: Minus, trash: Trash2, edit: Pencil, close: X, check: Check, - chevronDown: ChevronDown, chevronRight: ChevronRight, chevronLeft: ChevronLeft, chevronUp: ChevronUp, - arrowDown: ArrowDown, arrowUp: ArrowUp, back: ArrowLeft, forwardArrow: ArrowRight, undo: Undo2, redo: Redo2, - playNext: ListStart, playLater: ListEnd, pin: Pin, pinOff: PinOff, restore: History, seek: ArrowRight, sleep: Moon, bug: Bug, - selectAll: SquareCheck, grip: GripVertical, more: Ellipsis, minimize: Minus, maximize: Square, restoreWin: Copy, - spinner: LoaderCircle, offline: WifiOff, warn: TriangleAlert, battery: BatteryMedium, external: ExternalLink, folder: Folder, - copy: Copy, resume: RotateCcw, music: Music, cloud: Cloud, lan: Network, plus: Plus, -}; +/** Solid shapes read better filled; everything else defaults to outlined. */ +const FILLED_BY_DEFAULT = new Set(["play", "pause", "stop", "next", "previous", "rewind", "forward"]); /** Material Symbols names (what the registry's descriptors carry) → renderer names. */ export const MATERIAL: Record = { @@ -53,25 +34,31 @@ export interface IconProps { className?: string; style?: CSSProperties; title?: string; + /** Filled or outlined variant; defaults per glyph. */ + filled?: boolean; } /** Resolves a renderer or Material name to the renderer name that has a glyph. */ export function iconName(name: string): string { - return ICONS[name] ? name : (MATERIAL[name] ?? "music"); + return MATERIAL_ICONS[name] ? name : (MATERIAL[name] ?? "music"); } -export function Icon({ name, size = 16, className, style, title }: IconProps) { - const C = ICONS[iconName(name)] ?? Music; +const escapeText = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); + +export function Icon({ name, size = 16, className, style, title, filled }: IconProps) { + const n = iconName(name); + const [outline, solid] = MATERIAL_ICONS[n] ?? MATERIAL_ICONS.music ?? [""]; + const fill = filled ?? FILLED_BY_DEFAULT.has(n); + const body = fill ? (solid ?? outline) : outline; return ( - - {title ? {title} : null} - + ${escapeText(title)}` : "") + body }} /> ); } export function hasIcon(name: string): boolean { - return name in ICONS || name in MATERIAL; + return name in MATERIAL_ICONS || name in MATERIAL; } /** Every name the renderer can draw (for tests). */ -export const ICON_NAMES: readonly string[] = Object.keys(ICONS); +export const ICON_NAMES: readonly string[] = Object.keys(MATERIAL_ICONS); diff --git a/desktop/src/renderer/components/PlayerBar.tsx b/desktop/src/renderer/components/PlayerBar.tsx index 0c97e55..c9985c7 100644 --- a/desktop/src/renderer/components/PlayerBar.tsx +++ b/desktop/src/renderer/components/PlayerBar.tsx @@ -16,6 +16,7 @@ import { Artwork } from "./Artwork"; import { Icon } from "./Icon"; import { isOfflineNotice, noticeText } from "../lib/notice"; import { Heart, Stars } from "./Stars"; +import { Slider } from "./controls"; export function PlayerBar({ inert = false }: { inert?: boolean }) { const now = useApp((s) => s.nowPlaying); @@ -76,9 +77,9 @@ export function PlayerBar({ inert = false }: { inert?: boolean }) {
- - - + + +
@@ -185,7 +186,7 @@ function Volume() { return (
- { setMuted(undefined); set(Number(e.target.value)); }} onWheel={(e) => set(Math.max(0, Math.min(1, volume - Math.sign(e.deltaY) * 0.05)))} data-testid="volume" /> + { setMuted(undefined); set(v); }} onWheel={(e) => set(Math.max(0, Math.min(1, volume - Math.sign(e.deltaY) * 0.05)))} data-testid="volume" />
); } diff --git a/desktop/src/renderer/components/QueuePanel.tsx b/desktop/src/renderer/components/QueuePanel.tsx index 065db77..55c0013 100644 --- a/desktop/src/renderer/components/QueuePanel.tsx +++ b/desktop/src/renderer/components/QueuePanel.tsx @@ -1,10 +1,13 @@ -// One scrollable timeline: history above, current, "Playing next", then -// "Continuing from ". Drag-and-drop reorder (dnd-kit), Delete removes, -// double-click jumps. Selection keyed by queue key. +// One scrollable timeline, like Apple Music's: it rests with the current item +// at the top (Now playing, "Playing next" with Clear, "Continue playing — +// from "), and scrolling up reveals History (with Clear). Follows +// the current item when it changes unless the user is scrolling or working +// in the list. Drag-and-drop reorder (dnd-kit), Delete removes, double-click +// jumps. Selection keyed by queue key. // Keyboard: a listbox with aria-activedescendant (one Tab stop); arrows move, // Enter plays from the item, Delete removes, Alt+Up/Down reorders (the // keyboard equivalent of dragging), Shift+F10 opens the item's menu. -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -18,8 +21,12 @@ import { Artwork } from "./Artwork"; import { Icon } from "./Icon"; import { OfflineBadge } from "./OfflineBadge"; import { fmtTime } from "../lib/format"; +import { usePrefersReducedMotion } from "../lib/media"; import { EmptyState } from "./EmptyState"; +/** How long after the last scroll, wheel or pointer activity the list may follow the current item again. */ +const IDLE_MS = 4000; + const SCOPE = "queue"; export function QueuePanel({ large = false }: { large?: boolean }) { @@ -28,6 +35,7 @@ export function QueuePanel({ large = false }: { large?: boolean }) { const setSelection = useApp((s) => s.setSelection); const [focusKey, setFocusKey] = useState(undefined); const listRef = useRef(null); + const scrollRef = useRef(null); // Pointer drag only: the keyboard reorders with Alt+Up/Down on the listbox. const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const d = bridge().dispatch; @@ -98,22 +106,59 @@ export function QueuePanel({ large = false }: { large?: boolean }) { if (to >= 0) d({ type: "moveQueueItem", data: { key: String(active.id), to_index: to } }); }; - if (!queue.current && !all.length) return ; + // Rest with the current item at the top; follow it when it changes, unless + // the user scrolled, pointed or focused into the list in the last few seconds. + const reducedMotion = usePrefersReducedMotion(); + const lastTouch = useRef(0); + const programmatic = useRef(false); + const touched = useCallback(() => { + if (programmatic.current) return; + lastTouch.current = Date.now(); + }, []); + const currentKey = queue.current?.item.key; + const settled = useRef(false); + useLayoutEffect(() => { + const list = scrollRef.current; + const head = listRef.current?.querySelector('[data-group="current"]'); + if (!list || !head) return; + const first = !settled.current; + settled.current = true; + if (!first && (Date.now() - lastTouch.current < IDLE_MS || list.contains(document.activeElement) && document.activeElement !== list)) return; + const top = head.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop; + if (Math.abs(list.scrollTop - top) < 2) return; + programmatic.current = true; + list.scrollTo({ top, behavior: first || reducedMotion ? "auto" : "smooth" }); + window.setTimeout(() => { programmatic.current = false; }, first || reducedMotion ? 50 : 600); + }, [currentKey, reducedMotion]); + + if (!queue.current && !all.length) return ; const idPrefix = large ? "fsq" : "q"; + const header = large ? : null; const optionId = (key: string) => `${idPrefix}-${key.replace(/[^A-Za-z0-9_-]/g, "_")}`; const row = (e: QueueEntry, kind: "history" | "current" | "next" | "upcoming") => ( click(ev, e.item.key)} onDoubleClick={() => d({ type: "jumpToQueueItem", data: { key: e.item.key } })} onContextMenu={(ev) => onContext(ev, e.item.key)} /> ); - const group = (key: string, label: React.ReactNode, entries: QueueEntry[], kind: "history" | "current" | "next" | "upcoming") => + // Section headers label their group. A section's Clear button can't live in + // the listbox (it holds options only): it follows the list and is anchored + // onto its (sticky) header with CSS anchor positioning. + const anchor = (key: string) => `--${idPrefix}-sec-${key}`; + const group = (key: string, label: React.ReactNode, entries: QueueEntry[], kind: "history" | "current" | "next" | "upcoming", sub?: string) => entries.length ? ( -
-
{label}
+
+
+
+ {label} + {sub ? {sub} : null} +
+
{entries.map((e) => row(e, kind))}
) : null; - const upcomingLabel = queue.contextLabel ? t("queue.continuingFrom", { context: queue.contextLabel }) : t("queue.upcoming"); + const clearButton = (key: string, label: string, testId: string, onClick: () => void) => ( + + ); // Focus with no cursor yet starts on the current item. const onFocus = (e: React.FocusEvent) => { if (e.target !== e.currentTarget || focusKey) return; @@ -122,20 +167,23 @@ export function QueuePanel({ large = false }: { large?: boolean }) { }; const activeId = focusKey && keys.includes(focusKey) ? optionId(focusKey) : undefined; return ( -
- {/* "Clear playing next" lives outside the listbox: a listbox holds options only. */} - {queue.playingNext.length ?
: null} -
- - {group("history", t("queue.history"), queue.history, "history")} - {queue.current ? group("current", t("queue.nowPlaying"), [queue.current], "current") : null} - - {group("next", t("queue.playingNext"), queue.playingNext, "next")} - {group("upcoming", upcomingLabel, queue.upcoming, "upcoming")} - - +
+ {header} +
+
+ + {group("history", t("queue.history"), queue.history, "history")} + {queue.current ? group("current", t("queue.nowPlaying"), [queue.current], "current") : null} + + {group("next", t("queue.playingNext"), queue.playingNext, "next")} + {group("upcoming", t("queue.continuePlaying"), queue.upcoming, "upcoming", queue.contextLabel ? t("queue.fromContext", { context: queue.contextLabel }) : undefined)} + + +
+ {queue.history.length ? clearButton("history", t("queue.clearHistory"), "queue-clear-history", () => { d({ type: "removeQueueItems", data: { keys: queue.history.map((e) => e.item.key) } }); publish(EMPTY_SELECTION); }) : null} + {queue.playingNext.length ? clearButton("next", t("queue.clearInsertions"), "queue-clear-next", () => d({ type: "clearInsertions" })) : null} + {queue.totalUpcoming > queue.upcoming.length ?
{t("queue.more", { count: queue.totalUpcoming - queue.upcoming.length })}
: null}
- {queue.totalUpcoming > queue.upcoming.length ?
{t("queue.more", { count: queue.totalUpcoming - queue.upcoming.length })}
: null} {t("a11y.queueHint")}
); @@ -149,16 +197,30 @@ function QueueRow({ id, entry, kind, large, selected, focused, sortable, onClick
- {/* Pointer drag handle; the keyboard reorders with Alt+Up/Down on the list. */} - {sortable ? :
); } + +/** Shuffle, repeat and autoplay as tonal pill toggles over the fullscreen queue. */ +function QueueHeader() { + const queue = useApp((s) => s.queue); + const d = bridge().dispatch; + const repeatLabel = queue.repeat === "off" ? t("player.repeatOff") : queue.repeat === "all" ? t("player.repeatAll") : t("player.repeatOne"); + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/renderer/components/RightPanel.tsx b/desktop/src/renderer/components/RightPanel.tsx index 75fcf04..b7862e5 100644 --- a/desktop/src/renderer/components/RightPanel.tsx +++ b/desktop/src/renderer/components/RightPanel.tsx @@ -84,8 +84,8 @@ export function LyricsPane({ variant = "compact" }: { variant?: "compact" | "lar const now = useApp((s) => s.nowPlaying); const lyrics = useApp((s) => s.lyrics); const external = useApp((s) => s.settings[SK.lyricsExternalEnabled]?.value === "true"); - if (!now) return ; + if (!now) return ; const current = lyrics && lyrics.trackId === now.track.id ? lyrics.lyrics : undefined; - if (!current) return {t("lyrics.noneHint")} : undefined} testId="lyrics-empty" />; + if (!current) return {t("lyrics.noneHint")} : undefined} testId="lyrics-empty" />; return ; } diff --git a/desktop/src/renderer/components/SavedQueues.tsx b/desktop/src/renderer/components/SavedQueues.tsx index 418f2bd..674db43 100644 --- a/desktop/src/renderer/components/SavedQueues.tsx +++ b/desktop/src/renderer/components/SavedQueues.tsx @@ -12,7 +12,7 @@ import { executeAction } from "../store/actions"; export function SavedQueues() { const queues = useApp((s) => s.savedQueues); const d = bridge().dispatch; - if (!queues.length) return ; + if (!queues.length) return ; const sorted = [...queues].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastInteractedAt - a.lastInteractedAt); return (
diff --git a/desktop/src/renderer/components/Sidebar.tsx b/desktop/src/renderer/components/Sidebar.tsx index a9996dd..eff3fe0 100644 --- a/desktop/src/renderer/components/Sidebar.tsx +++ b/desktop/src/renderer/components/Sidebar.tsx @@ -46,7 +46,7 @@ export function Sidebar() {
{t("nav.library")}
{items.map((it) => ( { e.preventDefault(); if (it.view) navigate({ view: it.view }); else void executeAction(it.id); }} data-testid={`nav-${it.view ?? it.id}`}> - + {it.label} ))} @@ -58,7 +58,7 @@ export function Sidebar() { const name = filterName(f); return ( { e.preventDefault(); navigate(offline ? { view: "downloads", param: "offline" } : { view: "filter", id: f.id }); }} data-testid={offline ? "nav-available-offline" : undefined}> - + {name} ); @@ -66,7 +66,7 @@ export function Sidebar() { {(playlists ?? []).length ?
{t("nav.playlistsSection")}
: null} {(playlists ?? []).map((p) => ( { e.preventDefault(); navigate({ view: "playlist", id: p.id }); }} onContextMenu={(e) => void openContextMenu(e, { type: "playlists", data: { ids: [p.id] } })} title={p.isSmart ? `${p.name} · ${t("playlists.smart")}` : p.name}> - + {p.name} ))} @@ -74,7 +74,7 @@ export function Sidebar() { {/* Pinned below the scrolling list, like a native source list's footer. */} diff --git a/desktop/src/renderer/components/Stars.tsx b/desktop/src/renderer/components/Stars.tsx index 9f40934..b0f7e58 100644 --- a/desktop/src/renderer/components/Stars.tsx +++ b/desktop/src/renderer/components/Stars.tsx @@ -18,7 +18,7 @@ export function Stars({ value, onChange, size = 14, label, tabbable = true }: { if (!onChange) { return ( - {[1, 2, 3, 4, 5].map((n) => )} + {[1, 2, 3, 4, 5].map((n) => )} ); } @@ -58,7 +58,7 @@ export function Stars({ value, onChange, size = 14, label, tabbable = true }: { onKeyDown={(e) => onKey(e, n)} onClick={(e) => { e.stopPropagation(); onChange(n === value ? 0 : n); }} > - + ))} @@ -68,7 +68,7 @@ export function Stars({ value, onChange, size = 14, label, tabbable = true }: { export function Heart({ on, onToggle, size = 16, tabbable = true }: { on: boolean; onToggle?: () => void; size?: number; tabbable?: boolean }) { return ( ); } diff --git a/desktop/src/renderer/components/Tile.tsx b/desktop/src/renderer/components/Tile.tsx index 35615c2..772daaf 100644 --- a/desktop/src/renderer/components/Tile.tsx +++ b/desktop/src/renderer/components/Tile.tsx @@ -49,7 +49,7 @@ export function Tile({ title, subtitle, coverArt, round, onOpen, onActivate, onP {title} {subtitle ? {subtitle} : null} - {onPlay ? : null} + {onPlay ? : null} ); } diff --git a/desktop/src/renderer/components/TrackTable.tsx b/desktop/src/renderer/components/TrackTable.tsx index 473e2c5..686bcab 100644 --- a/desktop/src/renderer/components/TrackTable.tsx +++ b/desktop/src/renderer/components/TrackTable.tsx @@ -280,7 +280,7 @@ export function TrackTable(props: TrackTableProps) {
- {total === 0 ?
{props.emptyMessage ?? t("songs.empty")}
: null} + {total === 0 ?
{props.emptyMessage ?? t("songs.empty")}
: null}
{items.map((vi) => { const tr = tracks[vi.index]; @@ -332,9 +332,9 @@ function Cell({ col, track, index, playing, serverId, onPlay, primer }: { col: C case "index": return (
- {playing ? <>{t("a11y.playingTrack")} : {track.trackNumber ?? index + 1}} + {playing ? <>{t("a11y.playingTrack")} : {track.trackNumber ?? index + 1}} {/* Pointer shortcut (Enter plays the focused row); resting on it primes the track. */} - {!playing ? : null} + {!playing ? : null}
); case "art": diff --git a/desktop/src/renderer/components/WavySeek.tsx b/desktop/src/renderer/components/WavySeek.tsx new file mode 100644 index 0000000..27abd07 --- /dev/null +++ b/desktop/src/renderer/components/WavySeek.tsx @@ -0,0 +1,190 @@ +// The fullscreen player's seek bar: Material 3 Expressive's wavy progress. +// +// - The played part is a thick, round-capped sine wave travelling forward. +// Its amplitude is a spring: it swells when playback starts and settles +// flat when it pauses (or buffers). The wave tapers in from the start and +// into the thumb, so it never meets either with a kink. +// - A gap either side of the thumb; the rest of the track is flat and dimmed, +// with a stop indicator dot at its end. +// - The thumb is a rounded vertical bar that stretches while hovered or +// dragged, with a value bubble above it while dragging or keyboard-focused. +// - Reduced motion: the wave neither travels nor springs (it is simply wavy +// while playing and flat when paused). +// +// Same slider semantics and keys as the player bar's SeekBar. The wave is +// drawn by a requestAnimationFrame loop that writes the path directly (no +// React render per frame) and only runs while something is moving. +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { t } from "@shared/strings"; +import { useApp } from "../store/app"; +import { usePosition } from "../store/position"; +import { bridge } from "../core/bridge"; +import { fmtTime } from "../lib/format"; +import { seekKeyTarget, seekValueText } from "../lib/a11y"; +import { usePrefersReducedMotion } from "../lib/media"; + +export const WAVE = { + wavelength: 44, + amplitude: 4.5, + stroke: 6, + height: 40, + /** Gap either side of the thumb. */ + gap: 7, + /** Seconds for the wave to travel one wavelength. */ + period: 1.6, +}; +// Amplitude spring (unit mass): a little overshoot when it swells. +const STIFFNESS = 220; +const DAMPING = 2 * 0.65 * Math.sqrt(STIFFNESS); + +const smooth = (v: number) => { + const c = Math.max(0, Math.min(1, v)); + return c * c * (3 - 2 * c); +}; + +/** + * The played wave from x0 to x1 around `mid`: a polyline sampled every 2 px, + * its amplitude tapering to zero over the first wavelength and the last half + * wavelength. `phase` (px) moves the crests forward. + */ +export function wavePath(x0: number, x1: number, mid: number, amplitude: number, phase = 0, wavelength = WAVE.wavelength): string { + if (x1 <= x0) return ""; + const k = (2 * Math.PI) / wavelength; + const y = (x: number) => { + const taper = Math.min(smooth((x - x0) / wavelength), smooth((x1 - x) / (wavelength / 2))); + return mid - amplitude * taper * Math.sin(k * (x - phase)); + }; + let d = `M ${x0.toFixed(1)} ${y(x0).toFixed(2)}`; + for (let x = x0 + 2; x < x1; x += 2) d += ` L ${x.toFixed(1)} ${y(x).toFixed(2)}`; + return `${d} L ${x1.toFixed(1)} ${y(x1).toFixed(2)}`; +} + +export function WavySeek({ durationMs, playing }: { durationMs: number | undefined; playing: boolean }) { + const stamp = useApp((s) => s.transport.position); + const offset = useApp((s) => s.connection.clockOffsetMs); + const stopped = useApp((s) => s.perf === "stopped"); + const pos = usePosition(stamp, durationMs, offset); + const reducedMotion = usePrefersReducedMotion(); + const [drag, setDrag] = useState(undefined); + const [hover, setHover] = useState(undefined); + const [focused, setFocused] = useState(false); + const [width, setWidth] = useState(0); + const bar = useRef(null); + const path = useRef(null); + const clipId = useId(); + const dur = durationMs ?? 0; + + useLayoutEffect(() => { + const el = bar.current; + if (!el) return; + setWidth(el.getBoundingClientRect().width); + const ro = new ResizeObserver(() => setWidth(el.getBoundingClientRect().width)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const frac = (x: number) => { + const r = bar.current?.getBoundingClientRect(); + if (!r || !r.width) return 0; + return Math.max(0, Math.min(1, (x - r.left) / r.width)); + }; + const commit = useCallback((f: number) => bridge().dispatch({ type: "seekTo", data: { position_ms: Math.round(f * dur) } }), [dur]); + const dragging = drag !== undefined; + useEffect(() => { + if (!dragging) return; + const move = (e: PointerEvent) => setDrag(frac(e.clientX)); + const up = (e: PointerEvent) => { commit(frac(e.clientX)); setDrag(undefined); }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; + }, [dragging, commit]); + + const shown = drag !== undefined ? drag * dur : pos; + const f = dur ? Math.max(0, Math.min(1, shown / dur)) : 0; + const seconds = Math.floor(shown / 1000); + const onKey = (e: React.KeyboardEvent) => { + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; + const to = seekKeyTarget(e.key, shown, dur); + if (to === undefined) return; + e.preventDefault(); + e.stopPropagation(); + bridge().dispatch({ type: "seekTo", data: { position_ms: Math.round(to) } }); + }; + + const { stroke, gap, height } = WAVE; + const mid = height / 2; + const x0 = stroke / 2; + const xEnd = Math.max(x0, width - stroke / 2); + const x = x0 + f * (xEnd - x0); + const playedEnd = x - gap; + const restStart = x + gap; + const waving = playing && !dragging; + + // The wave's live state, read by the frame loop and by render. + const live = useRef({ amp: waving ? WAVE.amplitude : 0, vel: 0, phase: 0, x0, x1: playedEnd, mid }); + live.current.x0 = x0; + live.current.x1 = playedEnd; + live.current.mid = mid; + if (reducedMotion) { + live.current.amp = waving ? WAVE.amplitude : 0; + live.current.vel = 0; + } + const d = wavePath(x0, playedEnd, mid, live.current.amp, live.current.phase); + + useEffect(() => { + if (reducedMotion || stopped) return; + const target = waving ? WAVE.amplitude : 0; + const s = live.current; + if (!waving && Math.abs(s.amp) < 0.01 && Math.abs(s.vel) < 0.01) return; + let raf = 0; + let last = performance.now(); + const frame = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + s.vel += (-STIFFNESS * (s.amp - target) - DAMPING * s.vel) * dt; + s.amp += s.vel * dt; + s.phase = (s.phase + (WAVE.wavelength / WAVE.period) * dt) % WAVE.wavelength; + path.current?.setAttribute("d", wavePath(s.x0, s.x1, s.mid, Math.max(0, s.amp), s.phase)); + const settled = !waving && Math.abs(s.amp) < 0.01 && Math.abs(s.vel) < 0.01; + if (settled) { + s.amp = 0; + s.vel = 0; + path.current?.setAttribute("d", wavePath(s.x0, s.x1, s.mid, 0, s.phase)); + return; + } + raf = requestAnimationFrame(frame); + }; + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [waving, reducedMotion, stopped]); + + const bubble = dragging || focused; + return ( +
+
setFocused(e.currentTarget.matches(":focus-visible"))} + onBlur={() => setFocused(false)} + onPointerDown={(e) => { if (!dur || e.button !== 0) return; e.preventDefault(); e.currentTarget.focus({ preventScroll: true }); setDrag(frac(e.clientX)); }} + onPointerMove={(e) => setHover(frac(e.clientX))} + onPointerLeave={() => setHover(undefined)} + data-testid="fs-seek-slider"> + {width ? ( + + ) : null} + {hover !== undefined && dur && !dragging ?
{fmtTime(hover * dur)}
: null} + {bubble && dur ?
{fmtTime(shown)}
: null} +
+ +
+ ); +} diff --git a/desktop/src/renderer/components/controls.tsx b/desktop/src/renderer/components/controls.tsx new file mode 100644 index 0000000..550e5fc --- /dev/null +++ b/desktop/src/renderer/components/controls.tsx @@ -0,0 +1,252 @@ +// Material 3 Expressive form controls, drawn by us rather than the platform: +// +// - Slider: a thick rounded track split by a gap around a bar-shaped handle, +// a stop dot at the end, an optional value bubble. A transparent native +// lies over it, so the keyboard, assistive tech, +// form semantics and tests behave exactly as with the plain element. +// - Switch: an M3 switch (outlined when off; filled with a white handle +// carrying a check when on) over a transparent native checkbox (role +// "switch"), for the same reasons. +// - Select: a select-only combobox (ARIA 1.2 pattern) with an M3 menu of +// options, replacing the platform's onChange(Number(e.target.value))} onKeyDown={onKeyDown} aria-orientation={orientation === "vertical" ? "vertical" : undefined} {...aria} /> +
+ ); +} + +// ---- Switch --------------------------------------------------------------------------------- + +export interface SwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + id?: string; + className?: string; + "aria-label"?: string; + "aria-describedby"?: string; + "data-testid"?: string; +} + +export function Switch({ checked, onChange, disabled, id, className, ...aria }: SwitchProps) { + return ( + + onChange(e.target.checked)} {...aria} /> + + + ); +} + +// ---- Select --------------------------------------------------------------------------------- + +interface Opt { + value: string; + label: ReactNode; + text: string; + disabled: boolean; +} + +const textOf = (n: ReactNode): string => (typeof n === "string" || typeof n === "number" ? String(n) : Array.isArray(n) ? n.map(textOf).join("") : isValidElement<{ children?: ReactNode }>(n) ? textOf(n.props.children) : ""); + +function optionsOf(children: ReactNode): Opt[] { + const out: Opt[] = []; + Children.forEach(children, (c) => { + if (!isValidElement(c)) return; + const el = c as ReactElement<{ value?: string | number; children?: ReactNode; disabled?: boolean }>; + if (el.type === "option") out.push({ value: String(el.props.value ?? textOf(el.props.children)), label: el.props.children, text: textOf(el.props.children), disabled: !!el.props.disabled }); + else if (el.props.children) out.push(...optionsOf(el.props.children)); + }); + return out; +} + +export interface SelectProps { + value: string | number; + onChange: (e: { target: { value: string } }) => void; + children: ReactNode; + disabled?: boolean; + className?: string; + style?: CSSProperties; + id?: string; + "aria-label"?: string; + "aria-labelledby"?: string; + "data-testid"?: string; +} + +/** A select-only combobox with an M3 menu. Keys: Enter/Space/Alt+↓/↑/↓ open; ↑/↓/Home/End/type-ahead move; Enter/Space pick; Escape/Tab close. */ +export function Select({ value, onChange, children, disabled, className, style, id, ...aria }: SelectProps) { + const options = optionsOf(children); + const current = options.find((o) => o.value === String(value)); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const [pos, setPos] = useState<{ left: number; top?: number; bottom?: number; minWidth: number; maxHeight: number } | undefined>(undefined); + const button = useRef(null); + const list = useRef(null); + const typed = useRef({ text: "", at: 0 }); + const listId = useId(); + const optId = (i: number) => `${listId}-o${i}`; + + const place = () => { + const r = button.current?.getBoundingClientRect(); + if (!r) return; + const below = window.innerHeight - r.bottom - 12; + const above = r.top - 12; + const up = below < 220 && above > below; + setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - Math.max(r.width, 180) - 8)), minWidth: r.width, maxHeight: Math.min(360, up ? above : below), ...(up ? { bottom: window.innerHeight - r.top + 6 } : { top: r.bottom + 6 }) }); + }; + const openMenu = (at?: number) => { + if (disabled || !options.length) return; + place(); + const i = at ?? Math.max(0, options.findIndex((o) => o.value === String(value))); + setActive(i); + setOpen(true); + }; + const close = (refocus = true) => { + setOpen(false); + if (refocus) button.current?.focus({ preventScroll: true }); + }; + const pick = (i: number) => { + const o = options[i]; + if (!o || o.disabled) return; + if (o.value !== String(value)) onChange({ target: { value: o.value } }); + close(); + }; + const move = (from: number, dir: 1 | -1) => { + for (let k = 1; k <= options.length; k++) { + const i = (from + dir * k + options.length) % options.length; + if (!options[i]?.disabled) return i; + } + return from; + }; + const typeahead = (key: string) => { + const now = Date.now(); + typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : "") + key.toLowerCase(), at: now }; + const start = open ? active : Math.max(0, options.findIndex((o) => o.value === String(value))); + for (let k = 0; k < options.length; k++) { + const i = (start + (typed.current.text.length === 1 ? 1 : 0) + k) % options.length; + if (options[i]?.text.toLowerCase().startsWith(typed.current.text)) return i; + } + return undefined; + }; + + // Close on an outside pointer; follow the field on scroll and resize. + useEffect(() => { + if (!open) return; + const down = (e: PointerEvent) => { + const t = e.target as Node; + if (!list.current?.contains(t) && !button.current?.contains(t)) close(false); + }; + // Scrolling elsewhere (or a live page nudging its scroll position) keeps + // the menu with its field; it only closes once the field leaves the window. + let raf = 0; + const follow = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const r = button.current?.getBoundingClientRect(); + if (!r || r.bottom < 0 || r.top > window.innerHeight) close(false); + else place(); + }); + }; + const scroll = (e: Event) => { if (!list.current?.contains(e.target as Node)) follow(); }; + const resize = follow; + document.addEventListener("pointerdown", down, true); + window.addEventListener("scroll", scroll, true); + window.addEventListener("resize", resize); + return () => { + document.removeEventListener("pointerdown", down, true); + window.removeEventListener("scroll", scroll, true); + window.removeEventListener("resize", resize); + cancelAnimationFrame(raf); + }; + }, [open]); + useLayoutEffect(() => { + if (open) list.current?.querySelector(`#${CSS.escape(optId(active))}`)?.scrollIntoView({ block: "nearest" }); + }); + + const onKey = (e: React.KeyboardEvent) => { + if (disabled) return; + const k = e.key; + let handled = true; + if (!open) { + if (k === "Enter" || k === " " || k === "ArrowDown" || k === "ArrowUp") openMenu(); + else if (k === "Home") openMenu(0); + else if (k === "End") openMenu(options.length - 1); + else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const i = typeahead(k); if (i !== undefined) openMenu(i); } + else handled = false; + } else if (k === "ArrowDown") setActive(move(active, 1)); + else if (k === "ArrowUp") setActive(e.altKey ? active : move(active, -1)); + else if (k === "Home") setActive(0); + else if (k === "End") setActive(options.length - 1); + else if (k === "PageDown") setActive(Math.min(options.length - 1, active + 8)); + else if (k === "PageUp") setActive(Math.max(0, active - 8)); + else if (k === "Enter" || (k === " " && !typed.current.text)) pick(active); + else if (k === "Escape") close(); + else if (k === "Tab") { setOpen(false); handled = false; } + else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const i = typeahead(k); if (i !== undefined) setActive(i); } + else handled = false; + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + }; + + return ( + <> + + {open && pos ? createPortal( +
e.preventDefault()}> + {options.map((o, i) => ( +
setActive(i)} onClick={() => pick(i)}> + + {o.label} +
+ ))} +
, + document.body, + ) : null} + + ); +} diff --git a/desktop/src/renderer/components/icon.test.ts b/desktop/src/renderer/components/icon.test.ts index 06c7429..f48ef7a 100644 --- a/desktop/src/renderer/components/icon.test.ts +++ b/desktop/src/renderer/components/icon.test.ts @@ -1,5 +1,5 @@ // Every icon the action registry can emit (Material Symbols names in -// crates/hocket-core/src/actions/defs.rs) must map to a lucide glyph, and the +// crates/hocket-core/src/actions/defs.rs) must map to a Material Symbols glyph, and the // renderer's own names must all resolve; nothing falls through to the // placeholder silently. import { readFileSync } from "node:fs"; diff --git a/desktop/src/renderer/components/material-icons.ts b/desktop/src/renderer/components/material-icons.ts new file mode 100644 index 0000000..5a4a532 --- /dev/null +++ b/desktop/src/renderer/components/material-icons.ts @@ -0,0 +1,100 @@ +// Generated by scripts/gen-icons.mjs from @iconify-json/material-symbols +// (Material Symbols, Apache-2.0, rounded). Do not edit; edit the script's map. +// Each entry: [outlined, filled?] SVG bodies for a 24×24 viewBox. +export const MATERIAL_ICONS: Record = { + play: ["",""], + pause: ["",""], + stop: ["",""], + next: ["",""], + previous: ["",""], + rewind: ["",""], + forward: ["",""], + shuffle: [""], + repeat: [""], + repeatOne: [""], + autoplay: [""], + volume: ["",""], + mute: ["",""], + heart: ["",""], + heartOff: ["",""], + star: ["",""], + starOff: ["",""], + queue: [""], + lyrics: ["",""], + fullscreen: [""], + mini: ["",""], + devices: ["",""], + search: [""], + command: [""], + settings: ["",""], + home: ["",""], + library: ["",""], + album: ["",""], + artist: ["",""], + playlist: [""], + playlistAdd: [""], + song: [""], + genre: ["",""], + download: [""], + downloadOff: [""], + cached: ["",""], + filter: ["",""], + filterAdd: ["",""], + stats: [""], + info: ["",""], + remove: [""], + trash: ["",""], + edit: ["",""], + close: [""], + check: [""], + chevronDown: [""], + chevronRight: [""], + chevronLeft: [""], + chevronUp: [""], + arrowDown: [""], + arrowUp: [""], + back: [""], + forwardArrow: [""], + undo: [""], + redo: [""], + playNext: ["",""], + playLater: ["",""], + pin: ["",""], + pinOff: ["",""], + restore: [""], + seek: [""], + sleep: ["",""], + bug: ["",""], + selectAll: [""], + grip: [""], + dragHandle: [""], + more: [""], + moreVert: [""], + minimize: [""], + maximize: ["",""], + restoreWin: ["",""], + spinner: [""], + offline: [""], + warn: ["",""], + battery: ["",""], + external: [""], + folder: ["",""], + copy: ["",""], + resume: ["",""], + music: [""], + cloud: ["",""], + lan: ["",""], + plus: [""], + tune: [""], + equalizer: [""], + transcode: [""], + connectCast: ["",""], + palette: ["",""], + keyboard: ["",""], + backup: ["",""], + customise: ["",""], + headphones: ["",""], + schedule: ["",""], + dns: ["",""], + key: ["",""], +}; diff --git a/desktop/src/renderer/components/wavy-seek.test.ts b/desktop/src/renderer/components/wavy-seek.test.ts new file mode 100644 index 0000000..d034c7e --- /dev/null +++ b/desktop/src/renderer/components/wavy-seek.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { WAVE, wavePath } from "./WavySeek"; + +const points = (d: string) => d.replace(/^M /, "").split(" L ").map((p) => p.split(" ").map(Number) as [number, number]); + +describe("wavy seek path", () => { + it("is empty when nothing has played", () => { + expect(wavePath(3, 3, 20, WAVE.amplitude)).toBe(""); + }); + + it("tapers into the start and the thumb, and swings by the amplitude in between", () => { + const pts = points(wavePath(3, 400, 20, WAVE.amplitude)); + expect(pts[0]).toEqual([3, 20]); + expect(pts[pts.length - 1]).toEqual([400, 20]); + const ys = pts.map(([, y]) => y); + expect(Math.max(...ys)).toBeCloseTo(20 + WAVE.amplitude, 0); + expect(Math.min(...ys)).toBeCloseTo(20 - WAVE.amplitude, 0); + }); + + it("is a flat line at zero amplitude", () => { + expect(points(wavePath(3, 200, 20, 0)).every(([, y]) => y === 20)).toBe(true); + }); + + it("moves its crests forward with the phase", () => { + const a = points(wavePath(3, 400, 20, 4, 0)); + const b = points(wavePath(3, 400, 20, 4, WAVE.wavelength / 4)); + expect(a[100]![1]).not.toBeCloseTo(b[100]![1], 1); + }); +}); diff --git a/desktop/src/renderer/lib/spring.test.ts b/desktop/src/renderer/lib/spring.test.ts new file mode 100644 index 0000000..9790479 --- /dev/null +++ b/desktop/src/renderer/lib/spring.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { SPRING_EFFECTS, SPRING_SPATIAL, spring, springAt } from "./spring"; + +const values = (easing: string) => easing.replace(/^linear\(|\)$/g, "").split(", ").map(Number); + +describe("spring easing", () => { + it("starts at rest and ends exactly at 1", () => { + const v = values(spring(300, 0.7).easing); + expect(v[0]).toBe(0); + expect(v[v.length - 1]).toBe(1); + }); + + it("overshoots when underdamped and never when critically damped", () => { + expect(Math.max(...values(SPRING_SPATIAL.easing))).toBeGreaterThan(1); + expect(Math.max(...values(SPRING_EFFECTS.easing))).toBeLessThanOrEqual(1); + }); + + it("has settled by the reported duration", () => { + for (const [k, r] of [[260, 0.72], [700, 0.6], [400, 1]] as const) { + const { duration } = spring(k, r); + expect(Math.abs(1 - springAt(duration / 1000, k, r))).toBeLessThan(0.002); + expect(duration).toBeGreaterThan(100); + expect(duration).toBeLessThan(2000); + } + }); +}); diff --git a/desktop/src/renderer/lib/spring.ts b/desktop/src/renderer/lib/spring.ts new file mode 100644 index 0000000..3975abf --- /dev/null +++ b/desktop/src/renderer/lib/spring.ts @@ -0,0 +1,48 @@ +// Spring motion for CSS and the Web Animations API: a damped spring sampled +// into a `linear()` easing, so transitions and FLIP animations overshoot and +// settle like Material 3 Expressive's spatial springs without a JS loop. + +export interface Spring { + /** CSS easing: `linear(0, …, 1)`. */ + easing: string; + /** Milliseconds until the spring has settled (within 0.1 % of rest). */ + duration: number; +} + +/** + * Position (0 → 1) of a spring released from rest at 0 towards 1, at time `t` + * seconds. `stiffness` in N/m for a unit mass; `ratio` is the damping ratio + * (1 = critically damped, below 1 overshoots). + */ +export function springAt(t: number, stiffness: number, ratio: number): number { + const w = Math.sqrt(stiffness); + if (ratio < 1) { + const wd = w * Math.sqrt(1 - ratio * ratio); + return 1 - Math.exp(-ratio * w * t) * (Math.cos(wd * t) + ((ratio * w) / wd) * Math.sin(wd * t)); + } + return 1 - Math.exp(-w * t) * (1 + w * t); +} + +/** Sample a spring into a `linear()` easing and the duration it needs. */ +export function spring(stiffness: number, ratio: number, samples = 48): Spring { + const w = Math.sqrt(stiffness); + // The envelope e^(-ζωt) (or (1+ωt)e^(-ωt) when critically damped) below 0.001. + let settle = ratio < 1 ? Math.log(1000) / (ratio * w) : 0; + if (ratio >= 1) { + settle = 0.05; + while (Math.exp(-w * settle) * (1 + w * settle) > 0.001) settle += 0.01; + } + const points: string[] = []; + for (let i = 0; i <= samples; i++) { + const v = i === samples ? 1 : springAt((settle * i) / samples, stiffness, ratio); + points.push(String(Math.round(v * 1000) / 1000)); + } + return { easing: `linear(${points.join(", ")})`, duration: Math.round(settle * 1000) }; +} + +/** Large movements (the artwork flying between its slots): a gentle overshoot. */ +export const SPRING_SPATIAL = spring(260, 0.72); +/** Small, quick shape changes (the play button morphing, a pressed button). */ +export const SPRING_FAST = spring(700, 0.6); +/** Colour and opacity: no overshoot. */ +export const SPRING_EFFECTS = spring(400, 1); diff --git a/desktop/src/renderer/main.tsx b/desktop/src/renderer/main.tsx index 86b5aeb..8d26313 100644 --- a/desktop/src/renderer/main.tsx +++ b/desktop/src/renderer/main.tsx @@ -1,6 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./styles/global.css"; +import "./styles/m3-shell.css"; +import "./styles/m3-library.css"; +import "./styles/m3-pages.css"; +import "./styles/controls.css"; +import "./styles/now-playing.css"; import "@applemusic-like-lyrics/core/style.css"; import { App } from "./App"; import { MiniApp } from "./views/MiniApp"; diff --git a/desktop/src/renderer/store/actions.ts b/desktop/src/renderer/store/actions.ts index 364d274..c999095 100644 --- a/desktop/src/renderer/store/actions.ts +++ b/desktop/src/renderer/store/actions.ts @@ -45,6 +45,11 @@ export async function executeAction(rawId: string, target: ActionTarget = { type app.focusSearch(); return; case "toggleQueuePanel": + // In the fullscreen player Q and L switch its stage instead of the hidden side panel. + if (app.fullscreen) { + app.setNowPlayingMode(app.nowPlayingMode === "queue" ? "art" : "queue"); + return; + } if (isNarrow()) { // The drawer: open it on the queue, or close it when the queue is already showing. const showing = app.drawerOpen && !app.panels.queueCollapsed; @@ -55,6 +60,10 @@ export async function executeAction(rawId: string, target: ActionTarget = { type app.setPanels(app.panels.rightOpen && !app.panels.queueCollapsed ? { queueCollapsed: true } : { rightOpen: true, queueCollapsed: false }); return; case "toggleLyrics": + if (app.fullscreen) { + app.setNowPlayingMode(app.nowPlayingMode === "lyrics" ? "art" : "lyrics"); + return; + } if (isNarrow()) { const showing = app.drawerOpen && !app.panels.lyricsCollapsed; app.setDrawerOpen(!showing); diff --git a/desktop/src/renderer/store/app.ts b/desktop/src/renderer/store/app.ts index ebd33f7..d241c5f 100644 --- a/desktop/src/renderer/store/app.ts +++ b/desktop/src/renderer/store/app.ts @@ -21,6 +21,10 @@ export interface Route { param?: string; } +/** What the fullscreen player's stage shows: the big artwork, or one of its panes (the artwork shrinks next to the title). */ +export type NowPlayingMode = "art" | "lyrics" | "queue" | "about"; +const NOW_PLAYING_MODES: readonly NowPlayingMode[] = ["art", "lyrics", "queue", "about"]; + export interface PanelState { rightOpen: boolean; queueCollapsed: boolean; @@ -71,6 +75,8 @@ export interface UiState { /** Renderer performance mode derived from visibility/focus/battery. */ perf: "full" | "background" | "stopped"; queueTab: "queue" | "recent"; + /** Remembered on this device across closing and reopening the player. */ + nowPlayingMode: NowPlayingMode; prefs: AppPrefs; /** Narrow windows (and 200% zoom) show the side panel as a drawer, closed until asked for. */ drawerOpen: boolean; @@ -100,6 +106,7 @@ export interface AppStore extends CoreState, UiState { setWindowState(state: WindowState): void; setPageVisible(visible: boolean): void; setQueueTab(tab: "queue" | "recent"): void; + setNowPlayingMode(mode: NowPlayingMode): void; setPrefs(patch: Partial): void; setDrawerOpen(open: boolean): void; setting(key: string, fallback: T): T; @@ -129,6 +136,7 @@ export const useApp = create((set, get) => ({ pageVisible: true, perf: "full", queueTab: "queue", + nowPlayingMode: loadNowPlayingMode(), prefs: { closeToTray: true }, drawerOpen: false, @@ -216,6 +224,10 @@ export const useApp = create((set, get) => ({ setQueueTab(queueTab) { set({ queueTab }); }, + setNowPlayingMode(nowPlayingMode) { + saveLocal("nowPlayingMode", nowPlayingMode); + set({ nowPlayingMode }); + }, setPrefs(patch) { void bridge().prefs.set(patch).then((prefs) => set({ prefs })); }, @@ -237,6 +249,11 @@ export const useApp = create((set, get) => ({ }, })); +function loadNowPlayingMode(): NowPlayingMode { + const v = loadLocal("nowPlayingMode", "art"); + return (NOW_PLAYING_MODES as readonly string[]).includes(v) ? (v as NowPlayingMode) : "art"; +} + /** Whether the window is in the narrow layout (lib/media.ts NARROW). */ export function isNarrow(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 900px)").matches; diff --git a/desktop/src/renderer/store/keyboard.ts b/desktop/src/renderer/store/keyboard.ts index 63f94c2..4b8b59e 100644 --- a/desktop/src/renderer/store/keyboard.ts +++ b/desktop/src/renderer/store/keyboard.ts @@ -10,12 +10,13 @@ import { openContextMenuFromKeyboard } from "../components/ContextMenu"; export function useKeymap(): Keymap { const shortcuts = useApp((s) => s.shortcuts); + const platform = useApp((s) => s.meta?.platform ?? "linux"); return useMemo(() => { const merged = new Map(); for (const d of DEFAULT_KEYMAP) merged.set(d.actionId, d.shortcut); for (const s of shortcuts) merged.set(canonicalActionId(s.actionId), s.shortcut ?? undefined); - return new Keymap([...merged.entries()].map(([actionId, shortcut]) => ({ actionId, shortcut }))); - }, [shortcuts]); + return new Keymap([...merged.entries()].map(([actionId, shortcut]) => ({ actionId, shortcut })), platform); + }, [shortcuts, platform]); } /** diff --git a/desktop/src/renderer/store/shortcuts.test.ts b/desktop/src/renderer/store/shortcuts.test.ts index dc36e4d..306d94f 100644 --- a/desktop/src/renderer/store/shortcuts.test.ts +++ b/desktop/src/renderer/store/shortcuts.test.ts @@ -42,6 +42,22 @@ describe("shortcut parser", () => { expect(km.conflicts()).toEqual([{ chord: "mod+k", actionIds: ["a", "b"] }]); }); + it("treats a Ctrl binding as the platform modifier off macOS (the core spells its defaults that way)", () => { + const bindings = [{ actionId: "undo", shortcut: "Ctrl+Z" }, { actionId: "palette", shortcut: "Mod+K" }]; + const ev = (platform: "macOs" | "linux" | "windows", e: { key: string; code: string; ctrlKey?: boolean; metaKey?: boolean }) => + chordFromEvent({ altKey: false, shiftKey: false, ctrlKey: false, metaKey: false, ...e }, platform)!; + for (const platform of ["linux", "windows"] as const) { + const km = new Keymap(bindings, platform); + expect(km.lookup(ev(platform, { key: "z", code: "KeyZ", ctrlKey: true }))).toEqual(["undo"]); + expect(km.lookup(ev(platform, { key: "k", code: "KeyK", ctrlKey: true }))).toEqual(["palette"]); + } + // On macOS Ctrl stays Control; Cmd is the modifier. + const mac = new Keymap(bindings, "macOs"); + expect(mac.lookup(ev("macOs", { key: "z", code: "KeyZ", ctrlKey: true }))).toEqual(["undo"]); + expect(mac.lookup(ev("macOs", { key: "z", code: "KeyZ", metaKey: true }))).toEqual([]); + expect(mac.lookup(ev("macOs", { key: "k", code: "KeyK", metaKey: true }))).toEqual(["palette"]); + }); + it("recognises text inputs", () => { expect(isTextInput(null)).toBe(false); expect(isTextInput({ tagName: "INPUT", type: "text" } as unknown as HTMLElement)).toBe(true); diff --git a/desktop/src/renderer/store/shortcuts.ts b/desktop/src/renderer/store/shortcuts.ts index 5440792..ad4793c 100644 --- a/desktop/src/renderer/store/shortcuts.ts +++ b/desktop/src/renderer/store/shortcuts.ts @@ -131,11 +131,17 @@ export interface Binding { export class Keymap { private byChord = new Map(); - constructor(bindings: { actionId: string; shortcut: string | undefined }[]) { + /** + * Off macOS, Ctrl *is* the platform modifier: chordFromEvent reports it as + * `mod`, so a binding written "Ctrl+Z" (how the core spells its defaults + * there) is folded into "Mod+Z" to match. + */ + constructor(bindings: { actionId: string; shortcut: string | undefined }[], platform: "macOs" | "linux" | "windows" = "linux") { for (const b of bindings) { if (!b.shortcut) continue; - const c = parseChord(b.shortcut); - if (!c) continue; + const parsed = parseChord(b.shortcut); + if (!parsed) continue; + const c = platform !== "macOs" && parsed.ctrl ? { ...parsed, ctrl: false, mod: true } : parsed; const k = chordToString(c).toLowerCase(); this.byChord.set(k, [...(this.byChord.get(k) ?? []), b.actionId]); } diff --git a/desktop/src/renderer/styles/controls.css b/desktop/src/renderer/styles/controls.css new file mode 100644 index 0000000..d10b83e --- /dev/null +++ b/desktop/src/renderer/styles/controls.css @@ -0,0 +1,137 @@ +/* Material 3 Expressive form controls (components/controls.tsx): slider, + switch, select menu; plus number and colour inputs. One look everywhere; + contexts (the fullscreen player, the player bar) only swap the colours. + Loaded after the m3-* sheets, before now-playing.css. */ + +/* ---- Slider ------------------------------------------------------------------ + Geometry: the handle (--hw wide) is centred at --x; the active track runs + from the start to --gap before it, the inactive track from --gap after it + to the end, with a stop dot. The transparent native input on top has a + thumb exactly --hw wide, so pointer positions map to the same values. */ +.m3-slider { + --hw: 4px; --gap: 6px; --track: 14px; --handle-h: 30px; --r-out: 7px; --r-in: 3px; + --active: var(--accent); --inactive: var(--state-selected); --handle: var(--accent); --stop: var(--accent); + --x: calc(var(--hw) / 2 + (100% - var(--hw)) * var(--f, 0)); + position: relative; display: inline-flex; align-items: center; flex: 1 1 140px; min-width: 96px; height: var(--handle-h); +} +.m3-slider.sm { --hw: 3px; --gap: 4px; --track: 6px; --handle-h: 18px; --r-out: 3px; --r-in: 1.5px; } +.m3-slider-active, .m3-slider-inactive { position: absolute; top: 50%; height: var(--track); transform: translateY(-50%); pointer-events: none; } +.m3-slider-active { left: 0; width: max(0px, calc(var(--x) - var(--hw) / 2 - var(--gap))); background: var(--active); border-radius: var(--r-out) var(--r-in) var(--r-in) var(--r-out); } +.m3-slider-inactive { left: min(100%, calc(var(--x) + var(--hw) / 2 + var(--gap))); right: 0; background: var(--inactive); border-radius: var(--r-in) var(--r-out) var(--r-out) var(--r-in); } +/* The stop indicator: a dot at the end of the inactive track. */ +.m3-slider-inactive::after { content: ""; position: absolute; right: calc((var(--track) - 4px) / 2); top: 50%; width: 4px; height: 4px; border-radius: 50%; background: var(--stop); transform: translateY(-50%); } +.m3-slider.sm .m3-slider-inactive::after { display: none; } +.m3-slider-handle { position: absolute; top: 50%; left: var(--x); width: var(--hw); height: var(--handle-h); border-radius: var(--hw); background: var(--handle); transform: translate(-50%, -50%); pointer-events: none; transition: width var(--dur-short) var(--ease-emphasized), height var(--dur-spring) var(--spring-fast); } +.m3-slider:hover .m3-slider-handle { height: calc(var(--handle-h) + 4px); } +.m3-slider:has(.m3-slider-input:active) .m3-slider-handle { width: max(2px, calc(var(--hw) - 2px)); } +.m3-slider > .m3-slider-input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; } +.m3-slider-input::-webkit-slider-runnable-track { height: 100%; background: none; border: 0; } +.m3-slider-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: var(--hw); height: var(--handle-h); background: none; border: 0; } +.m3-slider:has(.m3-slider-input:focus-visible) { outline: 2px solid var(--focus-ring); outline-offset: 4px; border-radius: var(--shape-sm); } +.m3-slider.disabled { opacity: 0.38; } +.m3-slider.disabled .m3-slider-input { cursor: default; } +/* The value indicator: a filled pill popping up over the handle. */ +.m3-slider-bubble { position: absolute; bottom: calc(100% + 8px); left: 50%; min-width: 36px; padding: 6px 10px; border-radius: var(--shape-full); background: var(--fg); color: var(--bg); font-size: var(--fs-sm); font-weight: 600; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; transform: translateX(-50%) scale(0); transform-origin: 50% 100%; transition: transform var(--dur-spring) var(--spring-fast); } +.m3-slider:has(.m3-slider-input:active) .m3-slider-bubble, .m3-slider:has(.m3-slider-input:focus-visible) .m3-slider-bubble { transform: translateX(-50%) scale(1); } +/* Vertical: the same parts along the y axis, growing upwards (the native + input runs bottom-to-top via its writing mode). */ +.m3-slider.vertical { --y: calc(var(--hw) / 2 + (100% - var(--hw)) * var(--f, 0)); flex: 0 0 auto; width: var(--handle-h); height: 132px; min-width: 0; } +.m3-slider.vertical .m3-slider-active, .m3-slider.vertical .m3-slider-inactive { top: auto; left: 50%; width: var(--track); height: auto; transform: translateX(-50%); } +.m3-slider.vertical .m3-slider-active { bottom: 0; height: max(0px, calc(var(--y) - var(--hw) / 2 - var(--gap))); border-radius: var(--r-in) var(--r-in) var(--r-out) var(--r-out); } +.m3-slider.vertical .m3-slider-inactive { bottom: min(100%, calc(var(--y) + var(--hw) / 2 + var(--gap))); top: 0; right: auto; border-radius: var(--r-out) var(--r-out) var(--r-in) var(--r-in); } +.m3-slider.vertical .m3-slider-inactive::after { right: auto; top: calc((var(--track) - 4px) / 2); left: 50%; transform: translateX(-50%); } +.m3-slider.vertical .m3-slider-handle { top: auto; left: 50%; bottom: var(--y); width: var(--handle-h); height: var(--hw); transform: translate(-50%, 50%); transition: height var(--dur-short) var(--ease-emphasized), width var(--dur-spring) var(--spring-fast); } +.m3-slider.vertical:hover .m3-slider-handle { width: calc(var(--handle-h) + 4px); height: var(--hw); } +.m3-slider.vertical:has(.m3-slider-input:active) .m3-slider-handle { height: max(2px, calc(var(--hw) - 2px)); } +.m3-slider.vertical > .m3-slider-input { writing-mode: vertical-lr; direction: rtl; } +.m3-slider.vertical > .m3-slider-input::-webkit-slider-thumb { width: var(--handle-h); height: var(--hw); } +.m3-slider.vertical .m3-slider-bubble { bottom: calc(100% + 10px); } + +/* In the player bar and on the fullscreen player's artwork-coloured background. */ +.player .m3-slider { --active: var(--fg); --handle: var(--fg); --inactive: var(--bg-active); flex: 0 0 auto; width: 88px; min-width: 0; } +.fullscreen .m3-slider { --active: #fff; --handle: #fff; --stop: #fff; --inactive: rgba(255, 255, 255, 0.28); } +.fullscreen .m3-slider-bubble { background: #fff; color: #121214; } + +/* ---- Switch ------------------------------------------------------------------ + Off: an outlined track with a small handle. On: a filled track with a + white handle carrying a check. The handle grows while pressed. */ +.m3-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 52px; height: 32px; vertical-align: middle; } +.m3-switch > .m3-switch-input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 1; } +.m3-switch-track { position: absolute; inset: 0; border-radius: var(--shape-full); background: var(--bg-sunken); box-shadow: inset 0 0 0 2px var(--fg-muted); transition: background-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized); } +.m3-switch-handle { position: absolute; top: 50%; left: 16px; width: 16px; height: 16px; border-radius: 50%; background: var(--fg-muted); display: grid; place-items: center; color: var(--accent-text); transform: translate(-50%, -50%); transition: left var(--dur-spring) var(--spring-fast), width var(--dur-spring) var(--spring-fast), height var(--dur-spring) var(--spring-fast), background-color var(--dur-short) var(--ease-emphasized); } +.m3-switch-handle .icon { width: 16px; height: 16px; } +.m3-switch.on .m3-switch-track { background: var(--accent); box-shadow: none; } +.m3-switch.on .m3-switch-handle { left: 36px; width: 24px; height: 24px; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); } +.m3-switch:hover .m3-switch-handle { box-shadow: 0 0 0 8px var(--bg-hover); } +.m3-switch.on:hover .m3-switch-handle { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25), 0 0 0 8px color-mix(in srgb, var(--accent) 16%, transparent); } +.m3-switch:has(.m3-switch-input:active) .m3-switch-handle { width: 28px; height: 28px; } +.m3-switch:has(.m3-switch-input:focus-visible) .m3-switch-track { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +.m3-switch.disabled { opacity: 0.38; } +.m3-switch.disabled .m3-switch-input { cursor: default; } +.m3-switch.sm { width: 40px; height: 24px; } +.m3-switch.sm .m3-switch-handle { left: 12px; width: 12px; height: 12px; } +.m3-switch.sm.on .m3-switch-handle { left: 28px; width: 18px; height: 18px; } +.m3-switch.sm .m3-switch-handle .icon { width: 12px; height: 12px; } +label.switch { display: inline-flex; align-items: center; gap: 10px; } + +/* ---- Select ------------------------------------------------------------------ + The field: a filled pill with the value and a chevron that turns when open. + The menu: an M3 menu on the elevated surface; the chosen item carries a + check and a tonal fill, the active one a state layer. */ +.m3-select { + -webkit-appearance: none; appearance: none; font: inherit; color: var(--fg); + display: inline-flex; align-items: center; justify-content: space-between; gap: 6px; max-width: 100%; + height: var(--field-h); padding: 0 8px 0 16px; border: 0; border-radius: var(--shape-full); + background: var(--bg-sunken); box-shadow: inset 0 0 0 1px var(--border); cursor: pointer; text-align: left; + transition: border-radius var(--dur-spring) var(--spring-fast), background-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized); +} +.m3-select:hover { box-shadow: inset 0 0 0 1px var(--border-strong); background: color-mix(in srgb, var(--fg) 4%, var(--bg-sunken)); } +.m3-select.open { border-radius: var(--shape-md); box-shadow: inset 0 0 0 2px var(--accent); } +.m3-select:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +.m3-select:disabled { opacity: 0.38; cursor: default; } +.m3-select-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.m3-select-chevron { flex: 0 0 auto; color: var(--fg-muted); transition: transform var(--dur-spring) var(--spring-fast); } +.m3-select.open .m3-select-chevron { transform: rotate(180deg); color: var(--accent-text); } +.m3-menu { position: fixed; z-index: 120; overflow-y: auto; padding: 6px; background: var(--bg-elev); color: var(--fg); border-radius: var(--shape-lg); box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 2px; transform-origin: top center; animation: m3-menu-in var(--dur-spring) var(--spring-fast); } +@keyframes m3-menu-in { from { transform: scaleY(0.85); } } +.m3-option { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 16px 0 8px; border-radius: var(--shape-md); cursor: pointer; white-space: nowrap; transition: border-radius var(--dur-spring) var(--spring-fast); } +.m3-option-check { display: inline-flex; width: 18px; flex: 0 0 auto; color: var(--accent-text); } +.m3-option.active { background: var(--bg-hover); } +.m3-option.selected { background: var(--state-selected); font-weight: 600; border-radius: var(--shape-full); } +.m3-option[aria-disabled="true"] { opacity: 0.38; cursor: default; } + +/* ---- Number and colour inputs --------------------------------------------- */ +input.input[type="number"] { -moz-appearance: textfield; font-variant-numeric: tabular-nums; } +input.input[type="number"]::-webkit-inner-spin-button, input.input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +input.input[type="date"]::-webkit-calendar-picker-indicator { opacity: 0.7; cursor: pointer; border-radius: var(--shape-xs); } +input.input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; } +input[type="color"] { -webkit-appearance: none; appearance: none; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 50%; background: none; cursor: pointer; box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--border-strong); transition: border-radius var(--dur-spring) var(--spring-fast); } +input[type="color"]:hover { border-radius: var(--shape-md); } +input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; } +input[type="color"]::-webkit-color-swatch { border: 0; border-radius: inherit; } +input[type="color"]:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 6px; } + +/* ---- Forced colours ------------------------------------------------------- */ +@media (forced-colors: active) { + .m3-slider { forced-color-adjust: none; --active: Highlight; --handle: CanvasText; --stop: CanvasText; --inactive: GrayText; } + .m3-slider-bubble { background: Canvas; color: CanvasText; border: 1px solid CanvasText; } + .m3-switch { forced-color-adjust: none; } + .m3-switch-track { background: Canvas; box-shadow: inset 0 0 0 2px CanvasText; } + .m3-switch-handle { background: CanvasText; color: Canvas; } + .m3-switch.on .m3-switch-track { background: Highlight; box-shadow: none; } + .m3-switch.on .m3-switch-handle { background: HighlightText; color: Highlight; } + .m3-select { border: 1px solid ButtonText; } + .m3-menu { border: 1px solid CanvasText; } + .m3-option.active { outline: 1px solid Highlight; } + .m3-option.selected { forced-color-adjust: none; background: Highlight; color: HighlightText; } + input[type="color"] { border: 1px solid ButtonText; } +} + +/* ---- Placement ------------------------------------------------------------ */ +.setting-row .control .m3-slider:not(.vertical) { flex: 0 0 auto; width: 220px; } +.eq .m3-slider:not(.vertical) { flex: 0 0 auto; width: 180px; } +.eq .bands .m3-slider { --track: 12px; --handle-h: 28px; --r-out: 6px; } +.eq .bands .band { gap: 8px; } +.eq label.row { white-space: nowrap; } +/* Open, the menu's outline says where focus is; no second ring. */ +.m3-select.open:focus-visible { outline: none; } diff --git a/desktop/src/renderer/styles/global.css b/desktop/src/renderer/styles/global.css index fc754a6..ff54053 100644 --- a/desktop/src/renderer/styles/global.css +++ b/desktop/src/renderer/styles/global.css @@ -23,6 +23,50 @@ --accent-soft: color-mix(in srgb, var(--accent-tint) 18%, transparent); --accent-ring: var(--focus-ring); --ease: cubic-bezier(0.2, 0, 0, 1); + /* Material 3 Expressive shape scale: big, soft corners on containers, full + pills on controls. --radius / --radius-sm stay for the player bar. */ + --shape-xs: 6px; + --shape-sm: 10px; + --shape-md: 14px; + --shape-lg: 20px; + --shape-xl: 28px; + --shape-full: 999px; + /* Motion: emphasised easings for fades and colour, springs (sampled damped + oscillators, a little overshoot) for anything spatial — size, shape, + position. The reduced-motion block at the end turns all of it off. */ + --ease-emphasized: cubic-bezier(0.2, 0, 0, 1); + --ease-emphasized-decel: cubic-bezier(0.05, 0.7, 0.1, 1); + --ease-emphasized-accel: cubic-bezier(0.3, 0, 0.8, 0.15); + --spring-fast: linear(0, 0.1 6%, 0.308 11%, 0.531 17%, 0.723 22%, 0.864 28%, 0.957 33%, 1.009 39%, 1.033 44%, 1.038 50%, 1.034 56%, 1.026 61%, 1.018 67%, 1.01 72%, 1.005 78%, 1.002 83%, 1 89%, 1); + --spring-bouncy: linear(0, 0.087 5%, 0.285 9%, 0.52 14%, 0.739 18%, 0.913 23%, 1.033 27%, 1.101 32%, 1.125 36%, 1.12 41%, 1.098 45%, 1.069 50%, 1.04 55%, 1.016 59%, 0.999 64%, 0.989 68%, 0.985 73%, 0.984 77%, 0.987 82%, 0.99 86%, 0.994 91%, 0.997 95%, 1); + --dur-short: 150ms; + --dur-spring: 350ms; + --dur-spring-long: 500ms; + /* Tonal surfaces. A 5% accent wash over any theme surface keeps every text + colour at 4.5:1 plain and hovered (checked across the contrast.test accent + sweep), but not under --bg-active; so items on a tonal surface use the + opaque --state-selected below for pressed and highlighted states. */ + --surface-tonal: color-mix(in srgb, var(--accent-tint) 5%, var(--bg-elev)); + --surface-tonal-sunken: color-mix(in srgb, var(--accent-tint) 5%, var(--bg-sunken)); + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.12), 0 1px 3px 1px rgba(0, 0, 0, 0.06); + --shadow-2: 0 2px 6px 2px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.14); + /* One layout grid for every page. Filled blocks in a page body (heroes, + track-table headers, grouped lists, cards, genre cards) start at the + gutter; text that heads them (page titles, section headings) and the + content of hover-only rows and tiles sits one inset further in. */ + --page-gutter: 20px; + --page-title-inset: 8px; + --page-pad: calc(var(--page-gutter) + var(--page-title-inset)); + /* Type ramp for headings: one size per level everywhere. */ + --title-display: clamp(36px, 2.4vw + 18px, 52px); + --title-section: 22px; + --title-card: 18px; + /* One set of control sizes (M3 XS / S): text buttons, small buttons, the + larger header and dialog actions, and text fields. */ + --btn-h: 36px; + --btn-h-sm: 32px; + --btn-h-lg: 40px; + --field-h: 36px; } :root, :root[data-theme="light"] { @@ -45,6 +89,8 @@ --ok: #17683a; --shadow: 0 8px 28px rgba(0, 0, 0, 0.16), 0 1px 3px rgba(0, 0, 0, 0.1); --selection: color-mix(in srgb, var(--accent-tint) 22%, transparent); + /* The selection tint composited onto the elevated surface (opaque), so it can sit on a tonal surface without stacking tints. */ + --state-selected: color-mix(in srgb, var(--accent-tint) 22%, var(--bg-elev)); --accent-text: #5649bc; --accent-inverse: #8271fe; --accent-tint: #8777fd; @@ -69,6 +115,7 @@ --ok: #4ccb84; --shadow: 0 12px 36px rgba(0, 0, 0, 0.55), 0 1px 3px rgba(0, 0, 0, 0.4); --selection: color-mix(in srgb, var(--accent-tint) 30%, transparent); + --state-selected: color-mix(in srgb, var(--accent-tint) 30%, var(--bg-elev)); --accent-text: #9f93fa; --accent-inverse: #6453e3; --accent-tint: #6f5cff; @@ -107,7 +154,7 @@ main:focus-visible { outline: none; } /* Visible only to assistive technology. */ .sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } -.skip-link { position: absolute; left: 8px; top: -100px; z-index: 200; padding: 8px 12px; border-radius: var(--radius); background: var(--bg-elev); color: var(--fg); box-shadow: var(--shadow); } +.skip-link { position: absolute; left: 8px; top: -100px; z-index: 200; padding: 8px 16px; border-radius: var(--shape-full); background: var(--bg-elev); color: var(--fg); box-shadow: var(--shadow); } .skip-link:focus, .skip-link:focus-visible { top: 8px; outline: 2px solid var(--focus-ring); outline-offset: 0; } .plain-list { list-style: none; margin: 0; padding: 0; } /* Buttons that look like the content they wrap (tiles, rows, links). */ @@ -121,9 +168,11 @@ main:focus-visible { outline: none; } /* Scrollbars */ ::-webkit-scrollbar { width: 10px; height: 10px; } -::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--fg) 18%, transparent); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } -::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--fg) 30%, transparent); background-clip: padding-box; border: 2px solid transparent; } +::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--fg) 18%, transparent); border-radius: var(--shape-full); border: 3px solid transparent; background-clip: padding-box; min-height: 32px; min-width: 32px; } +::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--accent-text) 45%, transparent); background-clip: padding-box; border: 2px solid transparent; } +::-webkit-scrollbar-thumb:active { background: color-mix(in srgb, var(--accent-text) 65%, transparent); background-clip: padding-box; border: 2px solid transparent; } ::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-corner { background: transparent; } /* ---- Base controls ------------------------------------------------------ */ .btn { @@ -149,14 +198,55 @@ main:focus-visible { outline: none; } .btn.ghost:hover { background: var(--bg-hover); } .btn.ghost:active { background: var(--bg-active); } +/* ---- Material 3 Expressive controls --------------------------------------- + Pill buttons, circular icon buttons with state layers, tonal toggles and a + springy press that squeezes the pill toward a rounded square. Scoped away + from the player bar and the fullscreen player (which keep their own look); + :where() adds no specificity, so later contextual rules (.toast .btn, + .window-controls .btn, …) still win over these. */ +.btn:where(:not(.player *, .fullscreen *)) { + border-radius: var(--shape-full); padding: 0 14px; font-weight: 500; + transition: + background-color var(--dur-short) var(--ease-emphasized), + border-color var(--dur-short) var(--ease-emphasized), + color var(--dur-short) var(--ease-emphasized), + box-shadow var(--dur-short) var(--ease-emphasized), + border-radius var(--dur-spring) var(--spring-fast), + transform var(--dur-spring) var(--spring-bouncy); +} +.btn:where(:not(.player *, .fullscreen *)):active { transform: scale(0.95); border-radius: var(--shape-sm); transition-duration: 80ms, 80ms, 80ms, 80ms, 120ms, 120ms; } +.btn:where(:not(.icon, .player *, .fullscreen *)) { height: var(--btn-h); } +.btn.sm:where(:not(.icon, .player *, .fullscreen *)) { height: var(--btn-h-sm); padding: 0 12px; font-size: var(--fs-md); } +.btn.sm:where(:not(.player *, .fullscreen *)):active { border-radius: var(--shape-xs); } +.btn.primary:where(:not(.player *, .fullscreen *)) { box-shadow: var(--shadow-1); } +.btn.primary:where(:not(.player *, .fullscreen *)):hover { box-shadow: var(--shadow-2); } +/* Filled tonal: the secondary action next to a primary one (Shuffle beside Play). */ +.btn.tonal { background: var(--accent-soft); color: var(--fg); border-color: transparent; } +.btn.tonal:hover { background: var(--selection); border-color: transparent; } +.btn.tonal:active { background: var(--selection); } +/* Icon buttons: a circular state layer; a toggle that is on fills tonally. */ +.btn.icon:where(:not(.player *, .fullscreen *)) { padding: 0; border-radius: 50%; } +.btn.icon:where(:not(.player *, .fullscreen *)):active { border-radius: var(--shape-sm); } +.btn.icon:where(.on, [aria-pressed="true"]):where(:not(.heart, .player *, .fullscreen *)) { background: var(--accent-soft); color: var(--accent-text); } +.btn.icon:where(.on, [aria-pressed="true"]):where(:not(.heart, .player *, .fullscreen *)):hover { background: var(--selection); } +/* A toggle that is on reads as a rounded square, like M3's selected icon-button shape. */ +.btn.icon:where(.on, [aria-pressed="true"]):where(:not(.heart, .player *, .fullscreen *)) { border-radius: var(--shape-sm); } + +/* Filled fields: a sunken, softly rounded well whose edge only firms up on hover and focus. */ .input, .select, .textarea { - height: 28px; padding: 0 8px; border-radius: var(--radius); - border: 1px solid var(--border); background: var(--bg-sunken); color: var(--fg); + height: 28px; padding: 0 10px; border-radius: var(--shape-sm); + border: 1px solid transparent; background: var(--bg-sunken); color: var(--fg); + box-shadow: inset 0 0 0 1px var(--border); + transition: border-color var(--dur-short) var(--ease-emphasized), box-shadow var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); } -.textarea { height: auto; padding: 6px 8px; resize: vertical; font-family: var(--font-mono); font-size: var(--fs-sm); } -.input:focus, .select:focus, .textarea:focus { border-color: var(--focus-ring); } +.input:hover, .select:hover, .textarea:hover { box-shadow: inset 0 0 0 1px var(--border-strong); } +.textarea { height: auto; padding: 8px 10px; resize: vertical; font-family: var(--font-mono); font-size: var(--fs-sm); border-radius: var(--shape-md); } +.input:focus, .select:focus, .textarea:focus { border-color: var(--focus-ring); box-shadow: none; } .input:focus-visible, .select:focus-visible, .textarea:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -1px; } .input::placeholder { color: var(--fg-faint); } +/* Text fields share one height and shape (selects keep their own rules); a focused field squares off a little. */ +.input { height: var(--field-h); padding: 0 14px; border-radius: var(--shape-md); } +.input:focus { border-radius: var(--shape-sm); } .select { padding-right: 24px; appearance: auto; } input[type="range"] { accent-color: var(--accent); height: 20px; } input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; } @@ -171,24 +261,29 @@ input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; .row { display: flex; align-items: center; gap: 8px; } .grow { flex: 1 1 auto; min-width: 0; } .stack { display: flex; flex-direction: column; gap: 8px; } -.badge { display: inline-block; padding: 1px 6px; border-radius: 10px; font-size: var(--fs-xs); background: var(--bg-active); color: var(--fg-muted); line-height: 1.5; } +.badge { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 8px; border-radius: var(--shape-full); font-size: var(--fs-xs); font-weight: 600; background: var(--bg-active); color: var(--fg-muted); line-height: 1; white-space: nowrap; vertical-align: 1px; } +/* On a sunken or tonal container a neutral badge lifts onto the pane surface instead. */ +:is(.setting-row, .pin-row, .order-item, .shortcut-row) .badge:not(.synced) { background: var(--bg-elev); } .badge.synced { background: var(--accent-soft); color: var(--accent-text); } .badge.warn { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); } .badge.danger { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); } .badge.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); } -.kbd { display: inline-block; padding: 0 5px; min-width: 18px; text-align: center; border-radius: 3px; border: 1px solid var(--border-strong); border-bottom-width: 2px; font-size: var(--fs-xs); color: var(--fg-muted); background: var(--bg-elev); font-family: var(--font-ui); line-height: 16px; } +.kbd { display: inline-block; padding: 0 5px; min-width: 18px; text-align: center; border-radius: var(--shape-xs); border: 1px solid var(--border-strong); border-bottom-width: 2px; font-size: var(--fs-xs); color: var(--fg-muted); background: var(--bg-elev); font-family: var(--font-ui); line-height: 16px; } .divider { height: 1px; background: var(--border); margin: 6px 0; } -.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; } +.card { background: var(--surface-tonal-sunken); border-radius: var(--shape-xl); padding: 20px; min-width: 0; } h1, h2, h3 { margin: 0; font-weight: 600; } h1 { font-size: var(--fs-xxl); letter-spacing: -0.01em; } h2 { font-size: var(--fs-xl); } h3 { font-size: var(--fs-lg); } .section-title { font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fg-faint); padding: 8px 8px 4px; margin: 0; } /* Headings that keep the look of the old h3s at their new, correct level. */ -.section-heading, .card-title { font-size: var(--fs-lg); font-weight: 600; } +/* Section headings (h2 in a page body): one size and weight on every page, the + text one inset in from the blocks below, an optional tonal cookie icon. */ +.section-heading { display: flex; align-items: center; gap: 12px; font-size: var(--title-section); font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; margin: 0 0 12px var(--page-title-inset); } +/* Titles inside a card or side sheet. */ +.card-title { font-size: var(--title-card); font-weight: 700; letter-spacing: -0.005em; line-height: 1.2; margin: 0 0 14px; } .dialog-title { font-size: var(--fs-lg); font-weight: 600; margin: 0; } .pane-title { font-size: var(--fs-sm); font-weight: 600; margin: 0; } -.settings-heading { font-size: var(--fs-xl); margin-bottom: 12px; } /* ---- App shell ---------------------------------------------------------- */ .app { display: grid; grid-template-rows: auto 1fr var(--player-h); height: 100%; background: var(--bg); } @@ -201,14 +296,25 @@ h3 { font-size: var(--fs-lg); } .sidebar { display: flex; flex-direction: column; background: var(--bg-sunken); border-right: 1px solid var(--border); min-height: 0; min-width: 0; position: relative; } .sidebar-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 6px 6px 12px; } .sidebar-foot { flex: 0 0 auto; padding: 6px; border-top: 1px solid var(--border); } -.nav-item { display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 8px; border-radius: var(--radius); color: var(--fg-muted); white-space: nowrap; overflow: hidden; } +.sidebar .section-title { padding: 12px 12px 4px; } +/* Nav items are pills; the current one is a filled tonal indicator (M3 navigation drawer). */ +.nav-item { + display: flex; align-items: center; gap: 10px; height: 32px; padding: 0 12px; border-radius: var(--shape-full); color: var(--fg-muted); white-space: nowrap; overflow: hidden; font-weight: 500; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.nav-item + .nav-item { margin-top: 1px; } .nav-item:hover { background: var(--bg-hover); color: var(--fg); } -.nav-item:active { background: var(--bg-active); color: var(--fg); } -.nav-item.active { background: var(--accent-soft); color: var(--fg); box-shadow: inset 3px 0 0 var(--focus-ring); } -.nav-item .icon { flex: 0 0 auto; opacity: 0.85; } +.nav-item:active { background: var(--bg-active); color: var(--fg); transform: scale(0.97); border-radius: var(--shape-md); transition-duration: 80ms, 80ms, 120ms, 120ms; } +.nav-item.active { background: var(--accent-soft); color: var(--fg); font-weight: 600; } +.nav-item.active:hover { background: var(--selection); } +.nav-item.active:active { background: var(--selection); } +.nav-item .icon { flex: 0 0 auto; opacity: 0.85; transition: transform var(--dur-spring) var(--spring-bouncy); } +.nav-item.active .icon { opacity: 1; color: var(--accent-text); } .nav-item span { overflow: hidden; text-overflow: ellipsis; } .resize-handle { position: absolute; top: 0; bottom: 0; right: -3px; width: 6px; cursor: col-resize; z-index: 2; } -.resize-handle:hover, .resize-handle.dragging { background: var(--accent-soft); } +.resize-handle::after { content: ""; position: absolute; inset: 0 2px; border-radius: var(--shape-full); transition: background-color var(--dur-short) var(--ease-emphasized); } +.resize-handle:hover::after, .resize-handle.dragging::after { background: var(--accent-soft); } +.resize-handle.h::after { inset: 2px 0; } .resize-handle.h { position: relative; left: 0; right: 0; top: -3px; height: 6px; width: auto; cursor: row-resize; } /* Top bar */ @@ -216,16 +322,18 @@ h3 { font-size: var(--fs-lg); } .topbar > * { -webkit-app-region: no-drag; } .topbar.mac { padding-left: 80px; } .nav-buttons { display: flex; gap: 2px; } -.search { position: relative; width: 320px; max-width: 40vw; } -.search .input { width: 100%; padding-left: 28px; } -.search .icon { position: absolute; left: 8px; top: 7px; color: var(--fg-faint); pointer-events: none; } -.search-popover { position: absolute; top: 32px; right: 0; width: 520px; max-height: 70vh; overflow: auto; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); z-index: 30; padding: 6px 0; } +.search { position: relative; width: 340px; max-width: 40vw; } +/* The search field is a filled pill (M3 search bar), a touch taller than other fields. */ +.search .input { width: 100%; height: 32px; padding-left: 34px; padding-right: 14px; border-radius: var(--shape-full); } +.search .icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--fg-faint); pointer-events: none; } +.search-popover { position: absolute; top: 38px; right: 0; width: 520px; max-height: 70vh; overflow: auto; background: var(--surface-tonal); border: 1px solid var(--border); border-radius: var(--shape-lg); box-shadow: var(--shadow); z-index: 30; padding: 8px; transform-origin: top right; } .search-section { padding: 4px 0; } -.search-divider { display: flex; align-items: center; gap: 8px; margin: 6px 12px; color: var(--fg-faint); font-size: var(--fs-xs); } +.search-divider { display: flex; align-items: center; gap: 8px; margin: 6px 4px; color: var(--fg-faint); font-size: var(--fs-xs); } .search-divider::before, .search-divider::after { content: ""; flex: 1; height: 1px; background: var(--border); } -.result-row { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 12px; } -.result-row:hover, .result-row.active { background: var(--bg-hover); } -.result-row .art { width: 26px; height: 26px; border-radius: 3px; flex: 0 0 auto; } +.result-row { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 10px; border-radius: var(--shape-sm); } +.result-row:hover { background: var(--bg-hover); } +.result-row.active { background: var(--state-selected); } +.result-row .art { width: 26px; height: 26px; border-radius: var(--shape-xs); flex: 0 0 auto; } .result-row .kind { font-size: var(--fs-xs); color: var(--fg-faint); width: 56px; text-align: right; } /* Jobs indicator */ @@ -234,54 +342,78 @@ h3 { font-size: var(--fs-lg); } .jobs-btn .dot.problem { background: var(--warn); } .jobs-btn .spin { animation: spin 1.2s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } -.popover { position: absolute; top: 36px; right: 0; width: 380px; max-height: 70vh; overflow: auto; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); z-index: 30; padding: 8px 0; } +.popover { position: absolute; top: 38px; right: 0; width: 380px; max-height: 70vh; overflow: auto; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--shape-lg); box-shadow: var(--shadow); z-index: 30; padding: 8px 4px; transform-origin: top right; } .popover .item { padding: 6px 12px; display: flex; flex-direction: column; gap: 4px; } .popover .item + .item { border-top: 1px solid var(--border); } -.progress { height: 3px; border-radius: 2px; background: var(--bg-active); overflow: hidden; } -.progress > div { height: 100%; background: var(--accent); transition: width 0.25s var(--ease); } +.progress { height: 4px; border-radius: var(--shape-full); background: var(--bg-active); overflow: hidden; } +.progress > div { height: 100%; border-radius: inherit; background: var(--accent); transition: width 0.25s var(--ease); } .progress.failed > div { background: var(--warn); } /* Content */ .content { position: relative; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } .view { flex: 1; min-height: 0; display: flex; flex-direction: column; } -.view-header { display: flex; align-items: flex-end; gap: 12px; padding: 18px 20px 10px; } -.view-header .actions { display: flex; gap: 6px; margin-left: auto; align-items: center; } -.view-body { flex: 1; min-height: 0; overflow: auto; padding: 0 20px 20px; } +.view-header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 14px; padding: 28px var(--page-pad) 16px; } +.view-header .actions { display: flex; gap: 8px; margin-left: auto; align-items: center; align-self: center; } +.view-body { flex: 1; min-height: 0; overflow: auto; padding: 4px var(--page-gutter) 28px; } .view-body.no-pad { padding: 0; } .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; min-height: 160px; color: var(--fg-muted); text-align: center; gap: 8px; padding: 20px; } .empty .btn { margin-top: 4px; } /* Grids */ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--tile-w, 160px), 1fr)); gap: 16px 14px; padding: 4px 0; } -.tile { display: flex; flex-direction: column; gap: 6px; border-radius: var(--radius); padding: 6px; position: relative; min-width: 0; } -.tile-main { display: flex; flex-direction: column; gap: 6px; padding: 0; min-width: 0; width: 100%; border-radius: var(--radius-sm); } -.tile-main:focus-visible { outline-offset: 2px; } +/* Tiles are soft cards: the artwork's corners morph rounder and the art lifts + a little on a spring when hovered; a pressed tile squeezes in. */ +.tile { + display: flex; flex-direction: column; gap: 8px; border-radius: var(--shape-lg); padding: 8px; position: relative; min-width: 0; + /* A size container so the play button can sit on the artwork's corner whatever the text below. */ + container-type: inline-size; + transition: background-color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); +} +.tile-main { display: flex; flex-direction: column; gap: 8px; padding: 0; min-width: 0; width: 100%; border-radius: var(--shape-md); } +.tile-main:focus-visible { outline-offset: 2px; border-radius: var(--shape-md); } .tile:hover { background: var(--bg-hover); } +.tile:has(.tile-main:active) { transform: scale(0.97); transition-duration: 80ms, 120ms; } .tile.selected { background: var(--selection); } /* The keyboard cursor shows only while the grid has focus. */ -[role="grid"]:focus-within .tile.focused { box-shadow: inset 0 0 0 1px var(--accent-ring); } -.tile .art { display: block; width: 100%; height: auto; min-width: 0; flex: 0 0 auto; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-sunken); object-fit: cover; } +[role="grid"]:focus-within .tile.focused { box-shadow: inset 0 0 0 2px var(--accent-ring); } +.tile .art { + display: block; width: 100%; height: auto; min-width: 0; flex: 0 0 auto; aspect-ratio: 1; border-radius: var(--shape-md); background: var(--bg-sunken); object-fit: cover; + transition: border-radius var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring-long) var(--spring-bouncy), box-shadow var(--dur-short) var(--ease-emphasized); +} +.tile:hover .art:not(.round) { border-radius: var(--shape-xl); } +.tile:hover .art { transform: translateY(-2px); box-shadow: var(--shadow-2); } .tile .art.round { border-radius: 50%; } .tile .t1 { display: block; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tile-badge { display: inline-flex; align-items: center; padding: 3px; border-radius: 50%; color: var(--ok); background: var(--bg-elev); box-shadow: 0 1px 3px rgba(0,0,0,0.25); } +.tile-badge { display: inline-flex; align-items: center; padding: 3px; border-radius: var(--shape-full); color: var(--ok); background: var(--bg-elev); box-shadow: 0 1px 3px rgba(0,0,0,0.25); } .tile .t2 { display: block; color: var(--fg-muted); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tile .play { position: absolute; right: 12px; top: calc(100% - 78px); opacity: 0; transition: opacity 0.12s; background: var(--accent); color: var(--accent-fg); border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow); } -.tile:hover .play, .tile:focus-within .play { opacity: 1; } +.tile .play { + position: absolute; right: 16px; top: calc(100cqw - 40px); opacity: 0; transform: scale(0.6) translateY(4px); + background: var(--accent); color: var(--accent-fg); border: 0; border-radius: var(--shape-md); width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-2); + transition: opacity var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy), border-radius var(--dur-spring) var(--spring-fast); +} +.tile:hover .play, .tile:focus-within .play { opacity: 1; transform: none; } +/* The play button morphs from a rounded square to a circle under the pointer. */ +.tile .play:hover { border-radius: 50%; } +.tile .play:active { transform: scale(0.9); transition-duration: 80ms, 100ms, 120ms; } /* Table */ -.table { display: flex; flex-direction: column; min-height: 0; flex: 1; } +.table { display: flex; flex-direction: column; min-height: 0; flex: 1; --table-inset: 0px; } +/* A table that fills an unpadded body lines up with the page gutter like everything else. */ +.view-body.no-pad > .table { --table-inset: var(--page-gutter); } .table-head { display: grid; height: 28px; align-items: center; border-bottom: 1px solid var(--border); color: var(--fg-muted); font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.02em; padding: 0 12px; position: sticky; top: 0; background: var(--bg); z-index: 1; } .table-head .th { display: flex; align-items: center; gap: 4px; padding: 0 6px; height: 100%; white-space: nowrap; overflow: hidden; } .table-head .th.sortable { padding: 0; } .th-btn { display: flex; align-items: center; justify-content: inherit; gap: 4px; width: 100%; height: 100%; padding: 0 6px; font-weight: inherit; letter-spacing: inherit; } -.table-head .th.sortable:hover { color: var(--fg); background: var(--bg-hover); } +.table-head .th.sortable:hover { color: var(--fg); background: var(--bg-hover); border-radius: var(--shape-full); } +.th-btn { border-radius: var(--shape-full); } .table-head .th.sorted { color: var(--fg); } .table-body { flex: 1; min-height: 0; overflow: auto; position: relative; } -.tr { display: grid; height: var(--row-h); align-items: center; padding: 0 12px; border-radius: 3px; position: absolute; left: 0; right: 0; } +/* Rows are soft pills: rounded state layers on hover and selection, inset from the pane edge. */ +.tr { display: grid; height: var(--row-h); align-items: center; padding: 0 8px; border-radius: var(--shape-sm); position: absolute; left: var(--table-inset); right: var(--table-inset); transition: background-color var(--dur-short) var(--ease-emphasized); } .tr:hover { background: var(--bg-hover); } .tr.selected { background: var(--selection); } /* The keyboard cursor shows only while the table has focus. */ -.table:focus-within .tr.focused { box-shadow: inset 0 0 0 1px var(--accent-ring); } +.table:focus-within .tr.focused { box-shadow: inset 0 0 0 2px var(--accent-ring); } .tr.playing .td.title { color: var(--accent-text); font-weight: 500; } .tr .td { padding: 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tr .td.num, .table-head .th.num { text-align: right; justify-content: flex-end; font-variant-numeric: tabular-nums; color: var(--fg-muted); } @@ -294,24 +426,26 @@ h3 { font-size: var(--fs-lg); } .offline-badge.cached { color: var(--fg-muted); } /* The index cell's play button replaces the number on hover (the row is the Tab stop; Enter plays). */ .tr .td.index { position: relative; } -.tr .row-play { position: absolute; inset: 0 0 0 auto; width: 26px; display: flex; align-items: center; justify-content: center; opacity: 0; background: transparent; border: none; color: var(--fg); padding: 0; } +.tr .row-play { position: absolute; inset: 2px 0 2px auto; width: 26px; display: flex; align-items: center; justify-content: center; opacity: 0; background: transparent; border: none; border-radius: 50%; color: var(--fg); padding: 0; } +.tr .row-play:hover { background: var(--bg-hover); } .tr:hover .row-play, .tr .row-play:focus-visible { opacity: 1; } .tr:hover .td.index .n { visibility: hidden; } -.tr .art { width: 22px; height: 22px; border-radius: 3px; vertical-align: middle; margin-right: 8px; } +.tr .art { width: 22px; height: 22px; border-radius: var(--shape-xs); vertical-align: middle; margin-right: 8px; } .stars { display: inline-flex; gap: 1px; color: var(--fg-faint); } -.stars .star { display: inline-flex; padding: 0; line-height: 0; border-radius: 2px; } +.stars .star { display: inline-flex; padding: 0; line-height: 0; border-radius: var(--shape-xs); transition: transform var(--dur-spring) var(--spring-bouncy); } +.stars .star:active { transform: scale(0.8); transition-duration: 80ms; } .stars .star.on { color: var(--accent-text); } .stars:hover .star { color: var(--fg-faint); } .stars .star:hover, .stars .star:hover ~ .star.hovered { color: var(--accent-text); } .heart.on { color: var(--danger); } /* Detail header */ -.detail-head { display: flex; gap: 20px; padding: 20px 20px 12px; align-items: flex-end; } -.detail-head .art { width: 180px; height: 180px; border-radius: var(--radius); background: var(--bg-sunken); object-fit: cover; box-shadow: var(--shadow); flex: 0 0 auto; } +.detail-head { display: flex; gap: 24px; padding: 24px 24px 16px; align-items: flex-end; } +.detail-head .art { width: 188px; height: 188px; border-radius: var(--shape-xl); background: var(--bg-sunken); object-fit: cover; box-shadow: var(--shadow); flex: 0 0 auto; } .detail-head .art.round { border-radius: 50%; } .detail-head .meta { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .detail-head .meta h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.detail-head .meta .actions { display: flex; gap: 6px; margin-top: 8px; align-items: center; } +.detail-head .meta .actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; } /* Player bar */ .player { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(360px, 2fr) minmax(200px, 1fr); align-items: center; gap: 12px; padding: 0 14px; border-top: 1px solid var(--border); background: var(--bg-elev); position: relative; } @@ -347,10 +481,16 @@ h3 { font-size: var(--fs-lg); } .right-panel .pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } .right-panel .pane.collapsed { flex: 0 0 32px !important; } .pane-head { display: flex; align-items: center; gap: 4px; height: 32px; padding: 0 6px 0 10px; border-bottom: 1px solid var(--border); flex: 0 0 auto; } +/* Pane tabs are a compact pill segment: the selected one fills tonally. */ .pane-head .tabs { display: flex; gap: 2px; } -.pane-head .tab { height: 22px; padding: 0 8px; border-radius: var(--radius-sm); color: var(--fg-muted); font-size: var(--fs-sm); background: transparent; border: none; } +.pane-head .tab { + height: 24px; padding: 0 10px; border-radius: var(--shape-full); color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 500; background: transparent; border: none; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); +} .pane-head .tab:hover { background: var(--bg-hover); color: var(--fg); } -.pane-head .tab.active { background: var(--bg-active); color: var(--fg); } +.pane-head .tab:active { transform: scale(0.94); transition-duration: 80ms, 80ms, 120ms; } +.pane-head .tab.active { background: var(--accent-soft); color: var(--fg); } +.pane-head .tab.active:hover { background: var(--selection); } .pane-head .spacer { flex: 1; } .pane-body { flex: 1; min-height: 0; overflow: auto; } .queue-section { padding: 6px 10px 2px; color: var(--fg-faint); font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; position: sticky; top: 0; background: var(--bg-sunken); z-index: 1; } @@ -375,9 +515,9 @@ h3 { font-size: var(--fs-lg); } button.qrow { width: 100%; } .qrow:hover .grip { opacity: 1; } .qrow.dragging { opacity: 0.4; } -.sq-row { display: flex; gap: 10px; padding: 8px 10px; align-items: center; } +.sq-row { display: flex; gap: 10px; padding: 8px 10px; align-items: center; margin: 0 4px; border-radius: var(--shape-md); } .sq-row:hover { background: var(--bg-hover); } -.sq-row .art { width: 40px; height: 40px; border-radius: 3px; background: var(--bg-elev); } +.sq-row .art { width: 40px; height: 40px; border-radius: var(--shape-sm); background: var(--bg-elev); } /* Lyrics panel */ .lyrics-pane { position: relative; height: 100%; } @@ -388,7 +528,7 @@ button.qrow { width: 100%; } /* Background vocals: smaller, never fainter than the other lines (no opacity: that would cut contrast). */ .lyrics-static .bg { font-size: 0.7em; } .lyrics-static .duet { text-align: right; } -.lyrics-tools { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 4px; align-items: center; background: color-mix(in srgb, var(--bg-elev) 85%, transparent); border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 6px; font-size: var(--fs-xs); color: var(--fg-muted); opacity: 0; transition: opacity 0.15s; } +.lyrics-tools { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 4px; align-items: center; background: color-mix(in srgb, var(--bg-elev) 85%, transparent); border: 1px solid var(--border); border-radius: var(--shape-full); padding: 2px 8px; font-size: var(--fs-xs); color: var(--fg-muted); opacity: 0; transition: opacity 0.15s; } .lyrics-pane:hover .lyrics-tools, .lyrics-tools:focus-within { opacity: 1; } /* AMLL sizes itself from --amll-lp-font-size (its own default is 5vh, far too big for a side pane). The in-window view scales with the viewport and the @@ -405,46 +545,63 @@ button.qrow { width: 100%; } .lyrics-size .btn { min-width: 22px; font-size: var(--fs-xs); } /* Context menu, palette, dialogs, toasts */ -.menu { position: fixed; z-index: 100; min-width: 200px; max-width: 320px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 4px; } -.menu .mi { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 8px; border-radius: var(--radius-sm); color: var(--fg); white-space: nowrap; } -.menu .mi:hover, .menu .mi.active { background: var(--accent); color: var(--accent-fg); } +/* Menus: a tonal, generously rounded surface; the highlighted item is an + opaque tonal pill (--state-selected), never a tint stacked on the tint. */ +.menu { position: fixed; z-index: 100; min-width: 200px; max-width: 320px; background: var(--surface-tonal); border: 1px solid var(--border); border-radius: var(--shape-md); box-shadow: var(--shadow); padding: 6px; transform-origin: top left; } +.menu.fade-in { animation: pop-in var(--dur-spring) var(--spring-fast); } +.menu .mi { display: flex; align-items: center; gap: 10px; height: 28px; padding: 0 10px; border-radius: var(--shape-sm); color: var(--fg); white-space: nowrap; } +.menu .mi:hover, .menu .mi.active { background: var(--state-selected); color: var(--fg); } .menu .mi.disabled { color: var(--fg-faint); pointer-events: none; } .menu .mi.destructive { color: var(--danger); } -.menu .mi.destructive:hover, .menu .mi.destructive.active { background: var(--danger); color: var(--danger-fg); } +/* The danger colour reads at 4.5:1 on its own 14% tint over the elevated surface (contrast.test). */ +.menu .mi.destructive:hover, .menu .mi.destructive.active { background: color-mix(in srgb, var(--danger) 14%, var(--bg-elev)); color: var(--danger); } .menu:focus-visible { outline: none; } .menu .mi .kbd { margin-left: auto; } -.menu .mi:hover .kbd, .menu .mi.active .kbd { color: inherit; border-color: color-mix(in srgb, currentColor 40%, transparent); background: transparent; } -.menu .sep { height: 1px; background: var(--border); margin: 4px 6px; } - -.overlay { position: fixed; inset: 0; z-index: 90; background: rgba(0, 0, 0, 0.35); display: flex; align-items: flex-start; justify-content: center; padding-top: 12vh; } -.palette { width: 620px; max-width: 90vw; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); overflow: hidden; } -.palette .input { width: 100%; height: 44px; font-size: var(--fs-lg); border: none; border-bottom: 1px solid var(--border); border-radius: 0; background: transparent; padding: 0 16px; } -.palette .input:focus { box-shadow: none; } -.palette .list { max-height: 50vh; overflow: auto; padding: 6px; } -.palette .group { font-size: var(--fs-xs); color: var(--fg-faint); padding: 8px 10px 2px; text-transform: uppercase; letter-spacing: 0.05em; } -.palette .pi { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 10px; border-radius: var(--radius); } -.palette .pi.active { background: var(--accent); color: var(--accent-fg); } -.palette .pi .art { width: 26px; height: 26px; border-radius: 3px; } +.menu .mi:hover .kbd, .menu .mi.active .kbd { background: transparent; } +.menu .sep { height: 1px; background: var(--border); margin: 4px 10px; } +@keyframes pop-in { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: none; } } + +.overlay { position: fixed; inset: 0; z-index: 90; background: rgba(0, 0, 0, 0.35); display: flex; align-items: flex-start; justify-content: center; padding-top: 12vh; animation: scrim-in var(--dur-short) var(--ease-emphasized); } +@keyframes scrim-in { from { background: transparent; } } +/* The palette: an extra-rounded tonal sheet with a pill search field. */ +.palette { width: 620px; max-width: 90vw; background: var(--surface-tonal); border: 1px solid var(--border); border-radius: var(--shape-xl); box-shadow: var(--shadow); overflow: hidden; padding: 8px; transform-origin: top center; } +.palette.fade-in { animation: sheet-in var(--dur-spring-long) var(--spring-fast); } +.palette .input { width: 100%; height: 44px; font-size: var(--fs-lg); border: 1px solid transparent; border-radius: var(--shape-full); background: var(--bg-sunken); box-shadow: none; padding: 0 18px; } +.palette .input:hover { box-shadow: none; } +.palette .input:focus { box-shadow: none; border-color: transparent; } +.palette .list { max-height: 50vh; overflow: auto; padding: 6px 0 0; } +.palette .group { font-size: var(--fs-xs); color: var(--fg-faint); padding: 10px 12px 4px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } +.palette .pi { display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 12px; border-radius: var(--shape-md); } +.palette .pi.active { background: var(--state-selected); color: var(--fg); } +.palette .pi .art { width: 26px; height: 26px; border-radius: var(--shape-xs); } .palette .pi .sub { color: var(--fg-muted); font-size: var(--fs-sm); } -.palette .pi.active .sub { color: inherit; opacity: 0.8; } .palette .pi mark { background: transparent; color: var(--accent-text); font-weight: 600; } -.palette .pi.active mark { color: inherit; text-decoration: underline; } .palette .pi .kbd { margin-left: auto; } +@keyframes sheet-in { from { opacity: 0; transform: translateY(-8px) scale(0.96); } to { opacity: 1; transform: none; } } -.dialog { width: 440px; max-width: 90vw; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); padding: 18px; display: flex; flex-direction: column; gap: 12px; } +/* Dialogs: extra-large corners, a spring in. The surface stays --bg-elev (not + tonal) because the buttons, lists and fields inside use the pressed overlay. */ +.dialog { width: 440px; max-width: 90vw; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--shape-xl); box-shadow: var(--shadow); padding: 24px; display: flex; flex-direction: column; gap: 14px; } +.dialog.fade-in { animation: sheet-in var(--dur-spring-long) var(--spring-fast); } .dialog h3 { margin: 0; } -.dialog .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } -.dialog .list { max-height: 40vh; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius); } -.dialog .list .li { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; width: 100%; } +.dialog .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; } +.dialog .list { max-height: 40vh; overflow: auto; border: 1px solid var(--border); border-radius: var(--shape-md); padding: 4px; } +.dialog .list .li { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px; width: 100%; border-radius: var(--shape-sm); } .info-list { margin: 0; } .info-list dt, .info-list dd { margin: 0; } .dialog .list .li:hover { background: var(--bg-hover); } -.dialog label { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-sm); color: var(--fg-muted); } - -.toasts { position: fixed; bottom: calc(var(--player-h) + 12px); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; z-index: 80; align-items: center; } -.toast { display: flex; align-items: center; gap: 12px; background: var(--fg); color: var(--bg); padding: 8px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: var(--fs-md); } +.dialog label { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-md); font-weight: 500; color: var(--fg-muted); } + +.toasts { position: fixed; bottom: calc(var(--player-h) + 12px); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; z-index: 80; align-items: center; } +/* Snackbars: inverse-surface pills that spring up from below. */ +.toast { display: flex; align-items: center; gap: 12px; background: var(--fg); color: var(--bg); min-height: 40px; padding: 6px 8px 6px 18px; border-radius: var(--shape-lg); box-shadow: var(--shadow); font-size: var(--fs-md); } +.toast.fade-in { animation: toast-in var(--dur-spring-long) var(--spring-fast); } +/* Transform only: a toast is never drawn part-transparent, so its text keeps full contrast from the first frame. */ +@keyframes toast-in { from { transform: translateY(16px) scale(0.94); } to { transform: none; } } +.toast:not(:has(.btn)) { padding-right: 18px; } .toast .btn { background: transparent; color: var(--accent-inverse); border-color: transparent; font-weight: 600; } -.toast .btn:hover { background: color-mix(in srgb, var(--bg) 14%, transparent); } +/* The action is --accent-inverse at 4.5:1 on the toast itself; a lighter hover layer would drop it below that, so hover draws a ring in the text colour instead. */ +.toast .btn:hover, .toast .btn:active { background: transparent; box-shadow: inset 0 0 0 1.5px color-mix(in srgb, currentColor 60%, transparent); } .toast .btn:focus-visible { outline-color: var(--bg); } /* Setup */ @@ -452,10 +609,9 @@ button.qrow { width: 100%; } .setup form { width: 400px; max-width: 100%; display: flex; flex-direction: column; gap: 12px; margin: auto 0; } .setup-title { font-size: var(--fs-xl); } .setup .brand { font-size: var(--fs-xxl); font-weight: 700; letter-spacing: -0.02em; } -.setup label { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-sm); color: var(--fg-muted); } -.setup .input { height: 34px; } +.setup label { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-md); font-weight: 500; color: var(--fg-muted); } .setup .error { color: var(--danger); font-size: var(--fs-sm); } -.credential-warning { color: var(--fg); background: color-mix(in srgb, var(--danger) 12%, var(--bg-elev)); border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); border-radius: var(--radius); padding: 8px 10px; font-size: var(--fs-sm); margin-bottom: 8px; } +.credential-warning { color: var(--fg); background: color-mix(in srgb, var(--danger) 12%, var(--bg-elev)); border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); border-radius: var(--shape-md); padding: 10px 14px; font-size: var(--fs-sm); margin-bottom: 8px; } /* Settings */ .settings { display: grid; grid-template-columns: 200px 1fr; min-height: 0; flex: 1; } @@ -470,81 +626,63 @@ button.qrow { width: 100%; } .setting-row .control { display: flex; align-items: center; gap: 8px; } .setting-row.stacked { grid-template-columns: minmax(0, 1fr); } .cache-usage { display: flex; flex-direction: column; gap: 6px; width: 100%; } -.cache-bar { display: flex; height: 6px; border-radius: 3px; background: var(--bg-active); overflow: hidden; } +.cache-bar { display: flex; height: 8px; border-radius: var(--shape-full); background: var(--bg-active); overflow: hidden; } .cache-bar .complete { background: var(--accent); } .cache-bar .partial { background: color-mix(in srgb, var(--accent) 45%, var(--bg-active)); } .data-saved { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; } .data-saved .figure { font-size: var(--fs-lg, 18px); font-weight: 600; font-variant-numeric: tabular-nums; } -/* Tabs under a view header (Downloads). */ -.view-tabs-row { padding: 0 20px 8px; } -.view-tabs { display: flex; gap: 4px; } -.view-tabs .tab { height: 26px; padding: 0 12px; border-radius: 13px; border: none; background: transparent; color: var(--fg-muted); font-size: var(--fs-sm); } +/* Tabs under a view header (Downloads), with the page's status text on the right. */ +.view-tabs-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; padding: 0 var(--page-gutter) 16px; } +.view-tabs-row > .muted { margin-left: auto; padding-right: var(--page-title-inset); } +/* Segmented tabs (here and in the side panel's pane heads, m3-shell.css): a + sunken pill track; the selected segment fills with the opaque selected + tone and squares off slightly on its inner edge. */ +.view-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--shape-full); background: var(--bg-sunken); max-width: 100%; flex-wrap: wrap; } +.view-tabs .tab { + height: 34px; padding: 0 18px; border-radius: var(--shape-full); border: none; background: transparent; color: var(--fg-muted); font-size: var(--fs-md); font-weight: 600; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} .view-tabs .tab:hover { background: var(--bg-hover); color: var(--fg); } -.view-tabs .tab.active { background: var(--bg-active); color: var(--fg); } +.view-tabs .tab:active { transform: scale(0.95); transition-duration: 80ms, 80ms, 120ms, 120ms; } +.view-tabs .tab.active, .view-tabs .tab.active:hover { background: var(--state-selected); color: var(--fg); font-weight: 700; } +.view-tabs .tab.active:first-child { border-radius: var(--shape-full) var(--shape-sm) var(--shape-sm) var(--shape-full); } +.view-tabs .tab.active:last-child { border-radius: var(--shape-sm) var(--shape-full) var(--shape-full) var(--shape-sm); } .offline-head { padding: 4px 4px 8px; gap: 12px; align-items: center; } .eq { display: flex; flex-direction: column; gap: 8px; min-width: 0; } .eq > .row { flex-wrap: wrap; } -.eq canvas { width: 100%; height: 220px; border-radius: var(--radius); background: var(--bg-sunken); display: block; touch-action: none; } +.eq canvas { width: 100%; height: 220px; border-radius: var(--shape-lg); background: var(--bg-sunken); display: block; touch-action: none; } .eq .bands { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 4px; font-size: var(--fs-xs); color: var(--fg-muted); text-align: center; } .eq .band { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; } .eq .band input { width: 100%; min-width: 0; } -.order-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.order-list { border: 1px solid var(--border); border-radius: var(--shape-md); overflow: hidden; } .order-item { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; background: var(--bg-elev); } .order-list.plain-list { list-style: none; margin: 0; padding: 0; } .order-item + .order-item { border-top: 1px solid var(--border); } .order-item .grip { color: var(--fg-faint); cursor: grab; } .shortcut-row { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border); } .shortcut-row .rec { min-width: 130px; justify-content: flex-start; font-family: var(--font-ui); } -.shortcut-row .rec.recording { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } +.shortcut-row .rec.recording { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } .shortcut-row .conflict { color: var(--warn); font-size: var(--fs-xs); } /* Filters builder */ .rules { display: flex; flex-direction: column; gap: 6px; } -.rule-group { border: 1px solid var(--border); border-radius: var(--radius); padding: 8px; display: flex; flex-direction: column; gap: 6px; background: var(--bg-elev); } +.rule-group { display: flex; flex-direction: column; } .rule-group .group-head { display: flex; align-items: center; gap: 8px; } .rule { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -.rule .select, .rule .input { height: 26px; } /* Stats */ .stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } .bars { display: flex; align-items: flex-end; gap: 3px; height: 120px; } -.bars div { flex: 1; background: var(--accent); border-radius: 2px 2px 0 0; min-height: 2px; opacity: 0.85; } +.bars div { flex: 1; background: var(--accent); border-radius: var(--shape-xs) var(--shape-xs) 2px 2px; min-height: 2px; opacity: 0.85; } .bar-labels { display: flex; gap: 3px; font-size: 9px; color: var(--fg-faint); } .bar-labels span { flex: 1; text-align: center; } /* Fullscreen player */ /* Text here is white or 80% white; FluidBackground's scrim is sized so both reach 4.5:1 over the artwork. */ -.fullscreen { position: fixed; inset: 0; z-index: 50; background: #000; color: #fff; display: grid; grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr); overflow: hidden; --fg: #fff; --fg-muted: rgba(255,255,255,0.8); --fg-faint: rgba(255,255,255,0.8); --bg-hover: rgba(255,255,255,0.08); --bg-active: rgba(255,255,255,0.14); --border: rgba(255,255,255,0.15); --focus-ring: #fff; --accent-text: #fff; --accent-soft: rgba(255,255,255,0.12); --selection: rgba(255,255,255,0.16); } +.fullscreen { position: fixed; inset: 0; z-index: 50; background: #000; color: #fff; overflow: hidden; --fg: #fff; --fg-muted: rgba(255,255,255,0.8); --fg-faint: rgba(255,255,255,0.8); --bg-hover: rgba(255,255,255,0.08); --bg-active: rgba(255,255,255,0.14); --border: rgba(255,255,255,0.15); --focus-ring: #fff; --accent-text: #fff; --accent-soft: rgba(255,255,255,0.12); --selection: rgba(255,255,255,0.16); } .fullscreen canvas.bg, .fullscreen .bg-still { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; } .fullscreen .bg-still { filter: blur(60px) saturate(1.4) brightness(0.55); transform: scale(1.2); } .fullscreen .bg-shade { position: absolute; inset: 0; z-index: 0; } -.fullscreen .left { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; padding: 48px; min-width: 0; } -.fullscreen .cover { width: min(52vh, 100%); aspect-ratio: 1; border-radius: 12px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6); object-fit: cover; background: rgba(255,255,255,0.06); } -.fullscreen .info { text-align: center; max-width: 100%; } -.fullscreen .info .t1 { margin: 0; font-size: 22px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.fullscreen .info .t2 { font-size: 16px; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.fullscreen .fs-transport { width: min(52vh, 100%); display: flex; flex-direction: column; gap: 6px; } -.fullscreen .transport .btn.play { background: #fff; color: #000; width: 44px; height: 44px; } -.fullscreen .transport .btn.icon { color: #fff; } -.fullscreen .transport .btn.icon.play { background: #fff; color: #000; } -.fullscreen .transport .btn.icon.on { background: rgba(255, 255, 255, 0.18); } -.fullscreen .side { position: relative; z-index: 1; display: flex; flex-direction: column; min-height: 0; padding: 24px 24px 24px 0; } -.fullscreen .side .tabs { display: flex; gap: 4px; margin-bottom: 8px; } -.fullscreen .side .tab { background: transparent; border: none; color: var(--fg-muted); height: 28px; padding: 0 12px; border-radius: 14px; font-size: 14px; } -.fullscreen .side .tab:hover { background: rgba(255,255,255,0.08); color: #fff; } -.fullscreen .side .tab.active { background: rgba(255,255,255,0.15); color: #fff; } -.fullscreen .side .panel { flex: 1; min-height: 0; overflow: auto; border-radius: 10px; } -.fullscreen .close { position: absolute; top: 14px; right: 14px; z-index: 2; color: #fff; } -.fullscreen .qrow { height: 46px; } -/* Sticky section headers scroll over the rows: a dark glass, not the theme's light sunken grey. */ -.fullscreen .queue-section { background: rgba(12, 12, 16, 0.85); } -.fullscreen .related-row { display: flex; gap: 10px; padding: 8px 10px; align-items: center; border-radius: 6px; } -.fullscreen .related-row:hover { background: var(--bg-hover); } -.fullscreen .related-row .art { width: 40px; height: 40px; border-radius: 4px; } -.fullscreen .related-row .why { font-size: var(--fs-xs); color: var(--fg-faint); } -.fullscreen .seek { color: var(--fg-muted); } -.fullscreen .seek .bar .track { background: rgba(255,255,255,0.25); } -.fullscreen .seek .bar .fill { background: #fff; } /* Mini player */ .mini { height: 100%; display: grid; grid-template-columns: auto 1fr; background: var(--bg-elev); -webkit-app-region: drag; overflow: hidden; } @@ -558,28 +696,35 @@ button.qrow { width: 100%; } .mini .controls .spacer { flex: 1; } .mini .seek { font-size: 10px; } -/* Home */ -.home-section { margin-bottom: 20px; } -.home-section h2 { margin-bottom: 8px; display: flex; align-items: baseline; gap: 10px; } +/* Home (and the search page's album row): horizontal carousels. */ .home-section .scroller { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px; } .home-section .scroller .tile { width: 150px; flex: 0 0 auto; } /* Genres */ -.genre-tile { padding: 14px; border-radius: var(--radius); background: var(--bg-elev); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 4px; width: 100%; } -.genre-tile:hover { border-color: var(--border-strong); background: var(--bg-hover); } +.genre-tile { + padding: 16px; border-radius: var(--shape-lg); background: var(--bg-elev); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 4px; width: 100%; + transition: background-color var(--dur-short) var(--ease-emphasized), border-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.genre-tile:hover { border-color: var(--border-strong); background: var(--bg-hover); border-radius: var(--shape-xl); } +.genre-tile:active { transform: scale(0.97); transition-duration: 80ms, 80ms, 120ms, 120ms; } /* Downloads */ -.pin-row { display: grid; grid-template-columns: 44px 1fr auto auto; gap: 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); } -.pin-row .art { width: 44px; height: 44px; border-radius: 4px; } .filter-builder { display: grid; grid-template-columns: minmax(420px, 1fr) 300px; gap: 20px; } .window-controls { display: flex; gap: 0; margin-left: 4px; } .window-controls .btn { width: 36px; height: 28px; border-radius: 0; } +/* Native window controls stay square and still: no pill, no press squeeze. */ +.window-controls .btn:active, .window-controls .btn.icon:active { border-radius: 0; transform: none; } .window-controls .btn.close:hover { background: var(--danger); color: #fff; } -.fade-in { animation: fade 0.15s var(--ease); } +.fade-in { animation: fade var(--dur-short) var(--ease-emphasized-decel); } @keyframes fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } } -@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } +.popover.fade-in, .search-popover.fade-in { animation: pop-in var(--dur-spring) var(--spring-fast); } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } + /* No spatial feedback either: hover lifts and press squeezes become plain colour changes. */ + .btn:active, .nav-item:active, .tab:active, .tile:has(.tile-main:active), .tile .play, .tile .play:active, .tile:hover .art, .genre-tile:active, .stars .star:active { transform: none !important; } +} /* ---- Forced colours (Windows high contrast) ------------------------------ Backgrounds and box-shadows are dropped by the system palette, so every @@ -595,6 +740,11 @@ button.qrow { width: 100%; } .btn, .input, .select, .textarea, .tab, .kbd, .badge { border: 1px solid ButtonText; } .btn.icon, .btn.ghost, .tab { border-color: transparent; } .btn[aria-pressed="true"], .tab[aria-selected="true"], .lyrics-size .btn[aria-checked="true"] { border-color: Highlight; outline: 1px solid Highlight; } + /* States snap straight to the system colours: no cross-fade through blended, non-system ones. */ + .tr, .tile, .nav-item, .tab, .result-row, .genre-tile, .btn:where(:not(.player *, .fullscreen *)) { transition: none; } + /* Tonal "on" toggles outside the player bar (their fill is dropped by the system palette). */ + .btn.icon.on:where(:not(.heart, .player *, .fullscreen *)) { border-color: Highlight; outline: 1px solid Highlight; } + .view-tabs { border: 1px solid CanvasText; } .nav-item.active, .tr.selected, .tile.selected, .qrow.selected, .qrow.current, .menu .mi.active, .menu .mi:hover, .palette .pi.active, .result-row.active, .lyrics-static .active { forced-color-adjust: none; background: Highlight; color: HighlightText; } @@ -623,14 +773,16 @@ button.qrow { width: 100%; } rail, the side panel a drawer, the player bar two rows, and views reflow to one column so nothing scrolls sideways or is clipped. */ @media (max-width: 900px) { - h1 { font-size: 22px; } + :root { --page-gutter: 8px; --title-display: 32px; --title-section: 20px; } .app { grid-template-rows: auto minmax(0, 1fr) auto; } .app-body { grid-template-columns: 52px minmax(0, 1fr); position: relative; } .sidebar .section-title, .sidebar .nav-item span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .sidebar .nav-item { justify-content: center; padding: 0; } .sidebar-scroll, .sidebar-foot { padding: 4px; } .sidebar > .resize-handle { display: none; } - .right-panel.drawer { position: absolute; top: 0; right: 0; bottom: 0; width: min(340px, calc(100% - 52px)); z-index: 20; box-shadow: var(--shadow); } + .right-panel.drawer { position: absolute; top: 0; right: 0; bottom: 0; width: min(340px, calc(100% - 52px)); z-index: 20; box-shadow: var(--shadow); border-radius: var(--shape-lg) 0 0 var(--shape-lg); overflow: hidden; } + .sidebar .nav-item { height: 32px; } + .sidebar .nav-item:active { border-radius: var(--shape-sm); } .right-panel.drawer > .resize-handle { display: none; } .topbar { gap: 4px; padding: 0 6px; } .search { width: auto; flex: 1 1 120px; max-width: none; min-width: 0; } @@ -645,8 +797,8 @@ button.qrow { width: 100%; } .volume input { width: 64px; } .toasts { bottom: 150px; max-width: calc(100vw - 24px); } .toast { flex-wrap: wrap; } - .view-header { flex-wrap: wrap; padding: 12px 12px 8px; } - .view-body { padding: 0 12px 12px; } + .view-header { padding: 16px var(--page-pad) 12px; } + .view-body { padding: 4px var(--page-gutter) 16px; } .detail-head { flex-wrap: wrap; padding: 12px; gap: 12px; } .detail-head .art { width: 120px; height: 120px; } .detail-head .meta { flex: 1 1 200px; } @@ -661,14 +813,7 @@ button.qrow { width: 100%; } .shortcut-row { grid-template-columns: minmax(0, 1fr) auto; } .shortcut-row > :last-child { grid-column: 2; } .filter-builder { grid-template-columns: minmax(0, 1fr); } - .pin-row { grid-template-columns: 44px minmax(0, 1fr) auto; } .pin-row .badge { display: none; } - .fullscreen { grid-template-columns: minmax(0, 1fr); overflow-y: auto; } - .fullscreen .left { padding: 48px 16px 16px; gap: 12px; } - .fullscreen .cover { width: min(36vh, 60vw); } - .fullscreen .fs-transport, .fullscreen .info { width: 100% !important; } - .fullscreen .transport { flex-wrap: wrap; } - .fullscreen .side { padding: 0 16px 16px; min-height: 80vh; } .dialog { max-height: calc(100vh - 24px); overflow-y: auto; } .overlay { padding-top: 12px; } } diff --git a/desktop/src/renderer/styles/m3-library.css b/desktop/src/renderer/styles/m3-library.css new file mode 100644 index 0000000..3148727 --- /dev/null +++ b/desktop/src/renderer/styles/m3-library.css @@ -0,0 +1,253 @@ +/* m3-library: Material 3 Expressive pass for the library views (loaded after + global.css, before now-playing.css). Page content sits on the floating + --bg-elev content pane: display-size titles, counts as tonal chips, tiles + whose art morphs and lifts on springs, a scalloped "cookie" for artists, + squircle heroes with a large Play FAB, rounded track rows with a tonal + playing row, and genre cards carrying a coloured (decorative) shape. + Text only ever sits on --bg-elev, --bg-sunken or the contrast-checked + accent tints (--accent-soft, --surface-tonal, --selection). */ +:root { + /* Shapes from the M3 Expressive library: a 9-scallop cookie and a softer + 12-scallop one. Same point count, so clip-path morphs between them. */ + --lib-cookie-9: polygon(50% 0%, 52.17% 0.35%, 54.26% 1.36%, 56.20% 2.88%, 57.99% 4.70%, 59.62% 6.58%, 61.17% 8.30%, 62.72% 9.65%, 64.36% 10.53%, 66.19% 10.92%, 68.25% 10.87%, 70.53% 10.56%, 73% 10.16%, 75.54% 9.91%, 78.01% 10%, 80.25% 10.57%, 82.14% 11.70%, 83.57% 13.36%, 84.53% 15.47%, 85.04% 17.89%, 85.24% 20.43%, 85.28% 22.93%, 85.36% 25.24%, 85.68% 27.27%, 86.37% 29%, 87.52% 30.47%, 89.13% 31.75%, 91.08% 32.98%, 93.23% 34.27%, 95.33% 35.71%, 97.16% 37.36%, 98.52% 39.24%, 99.24% 41.32%, 99.27% 43.51%, 98.64% 45.74%, 97.49% 47.93%, 96% 50%, 94.43% 51.94%, 93.01% 53.76%, 91.94% 55.52%, 91.36% 57.29%, 91.30% 59.16%, 91.70% 61.17%, 92.41% 63.37%, 93.23% 65.73%, 93.91% 68.19%, 94.25% 70.64%, 94.08% 72.95%, 93.30% 75%, 91.91% 76.70%, 90% 78.01%, 87.71% 78.93%, 85.24% 79.57%, 82.79% 80.04%, 80.53% 80.53%, 78.58% 81.19%, 77% 82.17%, 75.75% 83.56%, 74.76% 85.36%, 73.89% 87.50%, 73% 89.84%, 71.95% 92.16%, 70.64% 94.25%, 69.02% 95.91%, 67.10% 96.98%, 64.94% 97.40%, 62.64% 97.16%, 60.29% 96.40%, 57.99% 95.30%, 55.80% 94.09%, 53.76% 93.01%, 51.85% 92.26%, 50% 92%, 48.15% 92.26%, 46.24% 93.01%, 44.20% 94.09%, 42.01% 95.30%, 39.71% 96.40%, 37.36% 97.16%, 35.06% 97.40%, 32.90% 96.98%, 30.98% 95.91%, 29.36% 94.25%, 28.05% 92.16%, 27% 89.84%, 26.11% 87.50%, 25.24% 85.36%, 24.25% 83.56%, 23% 82.17%, 21.42% 81.19%, 19.47% 80.53%, 17.21% 80.04%, 14.76% 79.57%, 12.29% 78.93%, 10% 78.01%, 8.09% 76.70%, 6.70% 75%, 5.92% 72.95%, 5.75% 70.64%, 6.09% 68.19%, 6.77% 65.73%, 7.59% 63.37%, 8.30% 61.17%, 8.70% 59.16%, 8.64% 57.29%, 8.06% 55.52%, 6.99% 53.76%, 5.57% 51.94%, 4% 50%, 2.51% 47.93%, 1.36% 45.74%, 0.73% 43.51%, 0.76% 41.32%, 1.48% 39.24%, 2.84% 37.36%, 4.67% 35.71%, 6.77% 34.27%, 8.92% 32.98%, 10.87% 31.75%, 12.48% 30.47%, 13.63% 29%, 14.32% 27.27%, 14.64% 25.24%, 14.72% 22.93%, 14.76% 20.43%, 14.96% 17.89%, 15.47% 15.47%, 16.43% 13.36%, 17.86% 11.70%, 19.75% 10.57%, 21.99% 10%, 24.46% 9.91%, 27% 10.16%, 29.47% 10.56%, 31.75% 10.87%, 33.81% 10.92%, 35.64% 10.53%, 37.28% 9.65%, 38.83% 8.30%, 40.38% 6.58%, 42.01% 4.70%, 43.80% 2.88%, 45.74% 1.36%, 47.83% 0.35%); + --lib-cookie-12: polygon(50% 0%, 52.17% 0.32%, 54.27% 1.19%, 56.27% 2.41%, 58.16% 3.71%, 60.01% 4.83%, 61.91% 5.57%, 63.91% 5.87%, 66.07% 5.83%, 68.37% 5.65%, 70.71% 5.59%, 72.96% 5.89%, 75% 6.70%, 76.72% 8.06%, 78.11% 9.86%, 79.22% 11.92%, 80.21% 14%, 81.26% 15.89%, 82.53% 17.47%, 84.11% 18.74%, 86% 19.79%, 88.08% 20.78%, 90.14% 21.89%, 91.94% 23.28%, 93.30% 25%, 94.11% 27.04%, 94.41% 29.29%, 94.35% 31.63%, 94.17% 33.93%, 94.13% 36.09%, 94.43% 38.09%, 95.17% 39.99%, 96.29% 41.84%, 97.59% 43.73%, 98.81% 45.73%, 99.68% 47.83%, 100% 50%, 99.68% 52.17%, 98.81% 54.27%, 97.59% 56.27%, 96.29% 58.16%, 95.17% 60.01%, 94.43% 61.91%, 94.13% 63.91%, 94.17% 66.07%, 94.35% 68.37%, 94.41% 70.71%, 94.11% 72.96%, 93.30% 75%, 91.94% 76.72%, 90.14% 78.11%, 88.08% 79.22%, 86% 80.21%, 84.11% 81.26%, 82.53% 82.53%, 81.26% 84.11%, 80.21% 86%, 79.22% 88.08%, 78.11% 90.14%, 76.72% 91.94%, 75% 93.30%, 72.96% 94.11%, 70.71% 94.41%, 68.37% 94.35%, 66.07% 94.17%, 63.91% 94.13%, 61.91% 94.43%, 60.01% 95.17%, 58.16% 96.29%, 56.27% 97.59%, 54.27% 98.81%, 52.17% 99.68%, 50% 100%, 47.83% 99.68%, 45.73% 98.81%, 43.73% 97.59%, 41.84% 96.29%, 39.99% 95.17%, 38.09% 94.43%, 36.09% 94.13%, 33.93% 94.17%, 31.63% 94.35%, 29.29% 94.41%, 27.04% 94.11%, 25% 93.30%, 23.28% 91.94%, 21.89% 90.14%, 20.78% 88.08%, 19.79% 86%, 18.74% 84.11%, 17.47% 82.53%, 15.89% 81.26%, 14% 80.21%, 11.92% 79.22%, 9.86% 78.11%, 8.06% 76.72%, 6.70% 75%, 5.89% 72.96%, 5.59% 70.71%, 5.65% 68.37%, 5.83% 66.07%, 5.87% 63.91%, 5.57% 61.91%, 4.83% 60.01%, 3.71% 58.16%, 2.41% 56.27%, 1.19% 54.27%, 0.32% 52.17%, 0% 50%, 0.32% 47.83%, 1.19% 45.73%, 2.41% 43.73%, 3.71% 41.84%, 4.83% 39.99%, 5.57% 38.09%, 5.87% 36.09%, 5.83% 33.93%, 5.65% 31.63%, 5.59% 29.29%, 5.89% 27.04%, 6.70% 25%, 8.06% 23.28%, 9.86% 21.89%, 11.92% 20.78%, 14% 19.79%, 15.89% 18.74%, 17.47% 17.47%, 18.74% 15.89%, 19.79% 14%, 20.78% 11.92%, 21.89% 9.86%, 23.28% 8.06%, 25% 6.70%, 27.04% 5.89%, 29.29% 5.59%, 31.63% 5.65%, 33.93% 5.83%, 36.09% 5.87%, 38.09% 5.57%, 39.99% 4.83%, 41.84% 3.71%, 43.73% 2.41%, 45.73% 1.19%, 47.83% 0.32%); + --lib-hero-title-size: clamp(32px, 2.6vw + 16px, 56px); +} + +/* ---- View headers (every page, the tool pages and settings included) -------- */ +.view-header h1, .settings-heading { + font-size: var(--title-display); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; + min-width: 0; overflow-wrap: anywhere; +} +/* The item count: a small tonal chip riding the title's baseline. */ +.view-header .count-chip { + display: inline-flex; align-items: center; height: 26px; padding: 0 12px; border-radius: var(--shape-full); + background: var(--accent-soft); color: var(--accent-text); font-weight: 700; font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; position: relative; top: -6px; +} +.view-header .count-chip:empty { display: none; } +.view-header .actions .btn:not(.icon) { height: var(--btn-h-lg); padding: 0 20px 0 16px; font-size: var(--fs-md); font-weight: 600; } +.view-header .actions .btn.tonal { color: var(--fg); } + +/* Sort: a connected button group — a pill select joined to the direction + toggle, each end fully round, the inner corners tight (M3 Expressive). */ +.sort-group { display: inline-flex; align-items: center; gap: 2px; } +.sort-group :is(.select, .m3-select) { + height: var(--btn-h-lg); padding: 0 14px 0 18px; border-radius: var(--shape-full) var(--shape-xs) var(--shape-xs) var(--shape-full); + background: var(--accent-soft); box-shadow: none; border-color: transparent; font-weight: 600; color: var(--fg); +} +.sort-group :is(.select, .m3-select):hover { background: var(--selection); box-shadow: none; } +.sort-group :is(.select, .m3-select):focus { border-color: transparent; } +.sort-group :is(.select, .m3-select):active { border-radius: var(--shape-sm); } +.sort-group .btn.icon:where(:not(.player *, .fullscreen *)) { + width: 44px; height: var(--btn-h-lg); border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); + background: var(--accent-soft); color: var(--fg); +} +.sort-group .btn.icon:where(:not(.player *, .fullscreen *)):hover { background: var(--selection); } +.sort-group .btn.icon[aria-pressed="true"]:where(:not(.player *, .fullscreen *)) { border-radius: var(--shape-xs) var(--shape-full) var(--shape-full) var(--shape-xs); color: var(--accent-text); } +.sort-group .btn.icon:where(:not(.player *, .fullscreen *)):active { border-radius: var(--shape-sm); } + +/* ---- Grids and tiles ------------------------------------------------------ */ +.tile { + gap: 8px; padding: 8px; border-radius: var(--shape-xl); + transition: background-color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); +} +.tile-main { gap: 8px; } +.tile:hover { background: var(--accent-soft); } +.tile.selected { background: var(--selection); } +.tile.selected:hover { background: var(--selection); } +.tile .art { + border-radius: var(--shape-lg); + transition: border-radius var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy), box-shadow var(--dur-short) var(--ease-emphasized), clip-path var(--dur-spring-long) var(--spring-fast); +} +/* Hover: the art rounds into a squircle and lifts on a bouncy spring. */ +.tile:hover .art:not(.round) { border-radius: 36px; } +.tile:hover .art { transform: translateY(-3px) scale(1.02); box-shadow: var(--shadow-2); } +/* Selected: the art settles back toward a squarer shape, like a selected control. */ +.tile.selected .art:not(.round) { border-radius: var(--shape-md); } +.tile .t1 { font-weight: 600; font-size: var(--fs-md); margin-top: 2px; } +.tile .t2 { margin-top: -6px; } +.tile-main .t2 { margin-top: -6px; } +/* Artists: a scalloped cookie that loosens into a softer 12-scallop one under the pointer. */ +.tile .art.round, .detail-head .art.round { border-radius: 0; clip-path: var(--lib-cookie-9); box-shadow: none; } +.tile:hover .art.round { clip-path: var(--lib-cookie-12); box-shadow: none; transform: scale(1.03) rotate(0deg); } + +/* The play FAB: a big squircle on the art's corner that morphs to a circle when pointed at. */ +.tile .play { + width: 48px; height: 48px; right: 18px; top: calc(100cqw - 50px); border-radius: 16px; + transform: scale(0.5) translateY(8px); +} +.tile:hover .play, .tile:focus-within .play { transform: none; } +.tile .play:hover { border-radius: 50%; box-shadow: var(--shadow); } +.tile .play:active { border-radius: var(--shape-sm); transform: scale(0.9); } +.tile-badge { top: 14px !important; left: 14px !important; padding: 5px; } + +/* ---- Home: carousels under expressive section headings ------------------- */ +.home-section { margin-bottom: 20px; } +.home-section:last-child { margin-bottom: 8px; } +.section-icon { + display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; flex: 0 0 auto; + clip-path: var(--lib-cookie-9); background: var(--selection); color: var(--accent-text); + transition: clip-path var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring-long) var(--spring-bouncy); +} +:is(.home-section, section):hover > .section-heading .section-icon { clip-path: var(--lib-cookie-12); transform: rotate(20deg); } +.home-section .scroller { + gap: 6px; padding: 6px 4px 12px; margin: -6px -4px 0; scroll-snap-type: x proximity; scroll-padding-inline: 4px; +} +/* The carousel's scrollbar stays out of the way until the pointer is over it. */ +.home-section .scroller::-webkit-scrollbar { height: 8px; } +.home-section .scroller::-webkit-scrollbar-thumb { background: transparent; background-clip: padding-box; border: 2px solid transparent; } +.home-section .scroller:hover::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--fg) 18%, transparent); background-clip: padding-box; } +.home-section .scroller .tile { width: 172px; scroll-snap-align: start; } + +/* ---- Detail heroes -------------------------------------------------------- */ +.detail-head { + margin: 12px var(--page-gutter); padding: 28px; gap: 28px; align-items: flex-end; border-radius: var(--shape-xl); + background-color: var(--bg-elev); + background-image: linear-gradient(135deg, var(--accent-soft) 0%, var(--surface-tonal) 72%); +} +/* Big squircle artwork. */ +.detail-head .art { width: 216px; height: 216px; border-radius: 48px; box-shadow: var(--shadow); } +.detail-head .art.round { width: 228px; height: 228px; } +.detail-head .meta { gap: 8px; flex: 1 1 auto; } +/* Eyebrow ("Albums", "Smart playlist"): a chip on the elevated surface. */ +.detail-head .meta > .muted.small:first-child { + align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: var(--shape-full); + background: var(--bg-elev); font-weight: 600; letter-spacing: 0.01em; +} +.detail-head .meta h1 { + font-size: var(--lib-hero-title-size); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; padding-bottom: 2px; + white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; +} +.detail-head .meta > div:not(.actions) { font-size: var(--fs-lg); } +.detail-head .meta > .small { font-size: var(--fs-sm); } +.detail-head .plain-link { font-weight: 700; } +.detail-head .meta .actions { gap: 10px; margin-top: 16px; flex-wrap: wrap; } +/* Play: a large extended FAB, squircle at rest, morphing to a full pill when pointed at. */ +.detail-head .meta .actions .btn.fab { + height: 56px; padding: 0 28px 0 20px; gap: 8px; border-radius: 18px; font-size: 17px; font-weight: 700; box-shadow: var(--shadow-2); +} +.detail-head .meta .actions .btn.fab:hover { border-radius: 28px; box-shadow: var(--shadow); } +.detail-head .meta .actions .btn.fab:active { border-radius: var(--shape-md); } +.detail-head .meta .actions .btn.tonal { height: 44px; padding: 0 18px 0 14px; font-size: var(--fs-lg); font-weight: 600; background: var(--bg-elev); } +.detail-head .meta .actions .btn.tonal:hover { background: var(--state-selected); } +/* Secondary actions: tonal icon buttons. */ +.detail-head .meta .actions .btn.icon { width: 44px; height: 44px; background: var(--bg-elev); } +.detail-head .meta .actions .btn.icon:hover { background: var(--state-selected); } +.detail-head .meta .actions .btn.icon svg { width: 22px; height: 22px; } +.detail-head .meta .actions .btn.icon:where(.on, [aria-pressed="true"]) { border-radius: var(--shape-md); } +.detail-head .meta .actions .stars { padding: 0 12px; height: 44px; align-items: center; border-radius: var(--shape-full); background: var(--bg-elev); } +.detail-head .meta .actions .stars svg { width: 18px; height: 18px; } +/* Artist pages: one section after another, with the page rhythm between them. */ +[data-testid="view-artist"] .view-body .section-heading { margin-top: 8px; } +[data-testid="view-artist"] .view-body .section-heading ~ .section-heading { margin-top: 28px; } + +/* ---- Track tables --------------------------------------------------------- */ +.table-head { + height: 34px; margin: 0 var(--table-inset) 6px; padding: 0 8px; border: 0; border-radius: var(--shape-full); + background: var(--bg-sunken); color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 600; letter-spacing: 0.01em; +} +.table-head .th.sortable:hover { background: var(--bg-hover); } +.table-head .th.sorted { color: var(--fg); } +.table-head .th.sorted .th-btn { background: var(--state-selected); color: var(--fg); } +.table-head .th.sortable .th-btn { transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); } +.table-head .th.sortable .th-btn:active { border-radius: var(--shape-sm); } +.tr { + border-radius: 15px; + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.tr:hover { background: var(--bg-hover); } +/* Selected rows morph toward a squarer shape; the playing row is filled tonally. */ +.tr.selected { background: var(--selection); border-radius: var(--shape-sm); } +.tr.playing { background: var(--accent-soft); } +.tr.playing:hover, .tr.playing.selected { background: var(--selection); } +.tr.playing .td.title { color: var(--fg); font-weight: 700; } +.tr.playing .td.index .icon { color: var(--accent-text); } +.tr .art { width: 24px; height: 24px; border-radius: 8px; } +.tr .row-play { background: var(--accent); color: var(--accent-fg); width: 24px; inset: 3px 0 3px auto; border-radius: 9px; transition: border-radius var(--dur-spring) var(--spring-fast), opacity var(--dur-short) var(--ease-emphasized); } +.tr .row-play:hover { background: var(--accent); border-radius: 50%; } +.table .empty .empty-cell { display: flex; flex-direction: column; align-items: center; gap: 12px; } + +/* ---- Genres: colourful tonal cards ---------------------------------------- */ +.genre-grid { --tile-w: 200px; gap: 12px; } +.genre-tile { + position: relative; min-height: 120px; justify-content: flex-start; gap: 4px; + padding: 18px 16px 16px 20px; border: 0; border-radius: var(--shape-xl); background: var(--bg-sunken); +} +.genre-tile:hover { background: var(--accent-soft); border-radius: var(--shape-lg); } +.genre-tile:active { border-radius: var(--shape-md); } +.genre-tile .genre-name { font-size: var(--fs-lg); font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; overflow-wrap: anywhere; } +/* Decorative only: a flex item below the text, so it never sits behind it. */ +.genre-tile::after { + content: ""; flex: 0 0 auto; align-self: flex-end; margin-top: auto; width: 44px; height: 44px; + clip-path: var(--lib-cookie-9); background: hsl(var(--genre-hue, 250) 62% 58%); + transition: transform var(--dur-spring-long) var(--spring-bouncy), clip-path var(--dur-spring-long) var(--spring-fast); +} +.genre-tile:hover::after { transform: rotate(40deg) scale(1.15); clip-path: var(--lib-cookie-12); } + +/* ---- Empty states: one glyph in a tonal cookie ---------------------------- */ +.empty { gap: 12px; font-size: var(--fs-lg); max-width: 520px; margin: 0 auto; } +.empty-icon { + display: inline-flex; align-items: center; justify-content: center; width: 80px; height: 80px; flex: 0 0 auto; + clip-path: var(--lib-cookie-9); background: var(--accent-soft); color: var(--accent-text); +} +.empty-cell { display: flex; flex-direction: column; align-items: center; gap: 12px; } +/* In the side panel the same empty state, a size down. */ +.right-panel .empty { font-size: var(--fs-md); gap: 10px; } +.right-panel .empty-icon { width: 56px; height: 56px; } +.right-panel .empty-icon svg { width: 24px; height: 24px; } + +/* ---- Stars and hearts ----------------------------------------------------- */ +.heart { transition: transform var(--dur-spring) var(--spring-bouncy), background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); } +.heart.on svg { animation: lib-heart-pop var(--dur-spring-long) var(--spring-bouncy); } +@keyframes lib-heart-pop { from { transform: scale(0.6); } to { transform: none; } } + +/* ---- Narrow windows and 200% zoom ----------------------------------------- */ +@media (max-width: 900px) { + .view-header .actions { margin-left: 0; } + .detail-head { margin: 8px var(--page-gutter); padding: 16px; gap: 16px; align-items: flex-start; } + .detail-head .art, .detail-head .art.round { width: 128px; height: 128px; border-radius: 32px; } + .detail-head .art.round { border-radius: 0; } + .detail-head .meta { flex: 1 1 220px; } + .detail-head .meta .actions { margin-top: 8px; } + .detail-head .meta .actions .btn.fab { height: 52px; padding: 0 22px 0 18px; border-radius: 18px; } + /* The carousels wrap into the same fluid grid as the library pages, so rows fill the width. */ + .home-section .scroller { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); margin: 0; padding: 0; } + .home-section .scroller .tile { width: auto; } + .genre-grid { --tile-w: 160px; } +} + +/* Short windows (200% zoom): a compact hero, and the page scrolls as a whole + so the hero's controls are never cut off; the track list keeps a bounded + height of its own for virtualisation. */ +@media (max-height: 640px) { + .detail-head { padding: 12px 14px; gap: 14px; margin: 6px var(--page-gutter); } + .detail-head .art, .detail-head .art.round { width: 88px; height: 88px; border-radius: 24px; } + .detail-head .art.round { border-radius: 0; } + .detail-head .meta h1 { font-size: 28px; } + .detail-head .meta > div:not(.actions) { font-size: var(--fs-md); } + .detail-head .meta .actions { margin-top: 6px; gap: 8px; } + .detail-head .meta .actions .btn.fab { height: 44px; padding: 0 18px 0 14px; border-radius: 14px; font-size: var(--fs-lg); } + .detail-head .meta .actions .btn.fab:hover { border-radius: 22px; } + .detail-head .meta .actions .btn.tonal, .detail-head .meta .actions .btn.icon, .detail-head .meta .actions .stars { height: 40px; } + .detail-head .meta .actions .btn.icon { width: 40px; } + .view:has(> .detail-head) { overflow-y: auto; } + .view:has(> .detail-head) > .view-body { flex: 0 0 auto; height: max(240px, 75vh); } +} + +/* ---- Reduced motion: no lifts, spins or pops (colour changes stay) ------- */ +@media (prefers-reduced-motion: reduce) { + .tile:hover .art, .tile:hover .art.round, .tile .play, .tile:hover .play, .tile .play:active, + :is(.home-section, section):hover > .section-heading .section-icon, .genre-tile:hover::after, .heart.on svg { transform: none !important; animation: none !important; } +} + +/* ---- Forced colours -------------------------------------------------------- */ +@media (forced-colors: active) { + .tr.selected, .tr.playing.selected, .tr.playing:hover, .tile.selected, .tile.selected:hover { + forced-color-adjust: none; background: Highlight; color: HighlightText; + } + .tr.playing:not(.selected) { outline: 1px solid CanvasText; outline-offset: -1px; } + .detail-head, .genre-tile, .table-head, .view-header .count-chip, .sort-group :is(.select, .m3-select) { border: 1px solid CanvasText; } + .genre-tile::after, .section-icon, .empty-icon { forced-color-adjust: none; background: GrayText; color: Canvas; } + .tr, .tile, .genre-tile, .tile .art { transition: none; } +} diff --git a/desktop/src/renderer/styles/m3-pages.css b/desktop/src/renderer/styles/m3-pages.css new file mode 100644 index 0000000..65690f0 --- /dev/null +++ b/desktop/src/renderer/styles/m3-pages.css @@ -0,0 +1,275 @@ +/* m3-pages: Material 3 Expressive pass (loaded after global.css, before now-playing.css). + Settings, first-run setup and the tool pages (Downloads, Filters, the filter + builder, Stats) plus the mini player window. The content pane is --bg-elev; + grouped containers sit on --surface-tonal-sunken (a 5% accent wash over the + sunken surface, contrast-checked in global.css) and anything selected fills + with the opaque --state-selected. Text only ever sits on those surfaces. */ + +/* ---- Shared ---------------------------------------------------------------- + Page padding, display titles, section headings, buttons and text fields come + from the shared tokens and rules in global.css / m3-library.css. */ +/* Settings section headings (h2.section-title) read like every other page's section heading. */ +.page-settings .section-title { + font-size: var(--title-section); font-weight: 750; letter-spacing: -0.01em; line-height: 1.2; text-transform: none; color: var(--fg); padding: 28px var(--page-title-inset) 0; +} + + +/* Switches, sliders and selects: components/controls.tsx + styles/controls.css. */ + +/* ---- Settings: pill section nav ------------------------------------------- */ +.page-settings .settings { grid-template-columns: 256px minmax(0, 1fr); } +.page-settings .settings-nav { border-right: 0; padding: 24px 8px 24px 16px; display: flex; flex-direction: column; gap: 2px; } +.page-settings .settings-nav .nav-item { height: 44px; padding: 0 18px 0 14px; gap: 14px; font-size: 14px; color: var(--fg-muted); flex: 0 0 auto; } +.page-settings .settings-nav .nav-item + .nav-item { margin-top: 0; } +.page-settings .settings-nav .nav-item .icon { opacity: 1; width: 20px; height: 20px; } +.page-settings .settings-nav .nav-item:hover .icon { transform: scale(1.1); } +.page-settings .settings-nav .nav-item.active { background: var(--state-selected); color: var(--fg); font-weight: 700; } +.page-settings .settings-nav .nav-item.active:hover, .page-settings .settings-nav .nav-item.active:active { background: var(--state-selected); } +.page-settings .settings-nav .nav-item:active { border-radius: var(--shape-sm); } +.page-settings .settings-body { padding: 28px 40px 56px var(--page-gutter); max-width: 920px; } +.page-settings .settings-heading { margin: 0 0 24px; padding-left: var(--page-title-inset); } +.page-settings .settings-heading + .section-title { padding-top: 4px; } +.page-settings .settings-body > .muted.small, .page-settings .settings-lede { font-size: var(--fs-md); margin-bottom: 16px !important; padding-left: var(--page-title-inset); } + +/* Grouped ("segmented") lists: consecutive rows share one container, split by + 2px gaps, with big corners only on the ends of each run. */ +.page-settings .setting-row { + background: var(--surface-tonal-sunken); border-bottom: 0; padding: 14px 20px; min-height: 64px; + border-radius: var(--shape-xs); margin-top: 2px; gap: 20px; +} +.page-settings .setting-row:not(.setting-row + .setting-row) { margin-top: 0; border-top-left-radius: var(--shape-lg); border-top-right-radius: var(--shape-lg); } +.page-settings .setting-row:not(:has(+ .setting-row)) { border-bottom-left-radius: var(--shape-lg); border-bottom-right-radius: var(--shape-lg); margin-bottom: 12px; } +.page-settings .setting-row .label { gap: 3px; } +.page-settings .setting-row .label .title { font-size: 14px; font-weight: 500; flex-wrap: wrap; } +.page-settings .setting-row .label .desc { font-size: var(--fs-md); } +.page-settings .setting-row .control { gap: 10px; } +.page-settings .setting-row .control .mono.small { font-variant-numeric: tabular-nums; } +.page-settings .setting-row .badge.synced { background: var(--state-selected); color: var(--fg); } +.page-settings .setting-row :is(.input, .m3-select) { background: var(--bg-elev); } +.page-settings .setting-row.stacked { padding: 18px 20px 20px; gap: 14px; } +/* A server: its name and details, then its actions underneath. */ +.page-settings .setting-row[data-testid="server-row"] { grid-template-columns: minmax(0, 1fr); gap: 12px; padding: 18px 20px; } +.page-settings .setting-row[data-testid="server-row"] .label .title { font-size: var(--fs-lg); font-weight: 700; } +.page-settings .setting-row[data-testid="server-row"] .label .title .muted { font-size: var(--fs-md); font-weight: 400; } +.page-settings .setting-row[data-testid="server-row"] .control { flex-wrap: wrap; } + +/* Storage: cache usage as an M3 linear progress (rounded segments with gaps and a stop dot). */ +.page-settings .cache-usage { gap: 10px; } +.page-settings .cache-bar { height: 12px; background: none; border-radius: 0; overflow: visible; } +.page-settings .cache-bar > div { border-radius: var(--shape-full); margin-right: 4px; flex: 0 1 auto; transition: width var(--dur-spring-long) var(--spring-fast); } +.page-settings .cache-bar > div[style^="width: 0%"] { margin-right: 0; } +.page-settings .cache-bar .partial { background: color-mix(in srgb, var(--accent) 50%, var(--state-selected)); } +.page-settings .cache-bar::after { + content: ""; flex: 1 1 0; min-width: 12px; border-radius: var(--shape-full); + background: radial-gradient(circle at calc(100% - 6px) 50%, var(--accent) 2px, transparent 2.5px), var(--state-selected); +} +.page-settings .data-saved { gap: 4px 14px; } +.page-settings .data-saved .figure { font-size: 40px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; } + +/* EQ: the curve sits in a big rounded well; the controls row wraps. */ +.page-settings .eq { gap: 14px; } +.page-settings .eq > .row { gap: 12px 18px; } +.page-settings .eq canvas { height: 240px; border-radius: var(--shape-xl); background: var(--bg-elev); } +.page-settings .eq .bands { gap: 8px; font-size: var(--fs-sm); } +.page-settings .eq .band { gap: 6px; } + +/* Customisation: the order lists are grouped lists too. */ +.page-settings .order-list { border: 0; border-radius: 0; overflow: visible; display: flex; flex-direction: column; gap: 2px; } +.page-settings .order-item { height: 48px; padding: 0 8px 0 12px; gap: 12px; background: var(--surface-tonal-sunken); border-radius: var(--shape-xs); } +.page-settings .order-item + .order-item { border-top: 0; } +.page-settings .order-item:first-child { border-top-left-radius: var(--shape-lg); border-top-right-radius: var(--shape-lg); } +.page-settings .order-item:last-child { border-bottom-left-radius: var(--shape-lg); border-bottom-right-radius: var(--shape-lg); } +.page-settings .order-item .grip { display: inline-flex; } + +/* Shortcuts: one grouped list per category; the recorder morphs while listening. */ +.page-settings .shortcut-group { display: flex; flex-direction: column; } +.page-settings .shortcut-group > .section-title::first-letter { text-transform: uppercase; } +.page-settings .shortcut-row { background: var(--surface-tonal-sunken); border-bottom: 0; padding: 8px 10px 8px 20px; min-height: 52px; border-radius: var(--shape-xs); gap: 10px; } +.page-settings .shortcut-row + .shortcut-row { margin-top: 2px; } +.page-settings .shortcut-group > .section-title + .shortcut-row { border-top-left-radius: var(--shape-lg); border-top-right-radius: var(--shape-lg); } +.page-settings .shortcut-row:last-child { border-bottom-left-radius: var(--shape-lg); border-bottom-right-radius: var(--shape-lg); } +.page-settings .shortcut-row .rec { height: 36px; min-width: 150px; background: var(--bg-elev); border-color: transparent; } +.page-settings .shortcut-row .rec.recording { background: var(--state-selected); color: var(--fg); border-color: transparent; border-radius: var(--shape-sm); box-shadow: 0 0 0 2px var(--accent); animation: m3p-listen 1.4s var(--ease-emphasized) infinite; } +@keyframes m3p-listen { 50% { box-shadow: 0 0 0 5px var(--accent-soft), 0 0 0 2px var(--accent); } } +.page-settings .shortcut-row .kbd { background: var(--bg-sunken); line-height: 20px; padding: 0 8px; border-radius: var(--shape-sm); font-size: var(--fs-sm); color: var(--fg); } +.page-settings .shortcut-row .conflict { font-size: var(--fs-sm); margin-top: 2px; } + +/* Diagnostics and about. */ +.page-settings .settings-body > .textarea { border-radius: var(--shape-lg); padding: 14px 16px; } +.page-settings .settings-body > p:first-of-type { font-size: var(--fs-lg); } + +/* The in-memory credentials notice: an error-tinted card with an icon. */ +.credential-warning { display: flex; align-items: flex-start; gap: 12px; border-radius: var(--shape-lg); padding: 14px 18px; font-size: var(--fs-md); border: 0; margin-bottom: 12px; } +.credential-warning .icon { flex: 0 0 auto; color: var(--danger); margin-top: -1px; } + +/* ---- First-run setup: an expressive hero beside a tonal card --------------- */ +.setup { padding: 32px; } +.setup form { width: 960px; max-width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 420px); gap: 56px; align-items: center; margin: auto 0; } +.setup-hero { display: flex; flex-direction: column; gap: 14px; min-width: 0; } +.setup .brand { font-size: clamp(64px, 8vw, 104px); font-weight: 800; letter-spacing: -0.045em; line-height: 0.92; color: var(--accent-text); overflow-wrap: anywhere; } +.setup .setup-title { font-size: 28px; font-weight: 700; letter-spacing: -0.015em; line-height: 1.15; } +.setup #setup-subtitle { font-size: var(--fs-lg); max-width: 38ch; } +/* Decorative M3 shapes above the wordmark. */ +.setup-shapes { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.setup-shapes .shape { display: block; width: 52px; height: 52px; flex: 0 0 auto; animation: m3p-shape-in var(--dur-spring-long) var(--spring-bouncy) both; } +.setup-shapes .cookie { + background: var(--accent); + clip-path: polygon(50% 0%, 54.3% 1.4%, 58% 4.7%, 61.2% 8.3%, 64.4% 10.5%, 68.2% 10.9%, 73% 10.2%, 78% 10%, 82.1% 11.7%, 84.5% 15.5%, 85.2% 20.4%, 85.4% 25.2%, 86.4% 29%, 89.1% 31.8%, 93.2% 34.3%, 97.2% 37.4%, 99.2% 41.3%, 98.6% 45.7%, 96% 50%, 93% 53.8%, 91.4% 57.3%, 91.7% 61.2%, 93.2% 65.7%, 94.3% 70.6%, 93.3% 75%, 90% 78%, 85.2% 79.6%, 80.5% 80.5%, 77% 82.2%, 74.8% 85.4%, 73% 89.8%, 70.6% 94.3%, 67.1% 97%, 62.6% 97.2%, 58% 95.3%, 53.8% 93%, 50% 92%, 46.2% 93%, 42% 95.3%, 37.4% 97.2%, 32.9% 97%, 29.4% 94.3%, 27% 89.8%, 25.2% 85.4%, 23% 82.2%, 19.5% 80.5%, 14.8% 79.6%, 10% 78%, 6.7% 75%, 5.7% 70.6%, 6.8% 65.7%, 8.3% 61.2%, 8.6% 57.3%, 7% 53.8%, 4% 50%, 1.4% 45.7%, 0.8% 41.3%, 2.8% 37.4%, 6.8% 34.3%, 10.9% 31.8%, 13.6% 29%, 14.6% 25.2%, 14.8% 20.4%, 15.5% 15.5%, 17.9% 11.7%, 22% 10%, 27% 10.2%, 31.8% 10.9%, 35.6% 10.5%, 38.8% 8.3%, 42% 4.7%, 45.7% 1.4%); +} +.setup-shapes .pill { width: 88px; border-radius: var(--shape-full); background: var(--state-selected); animation-delay: 60ms; } +.setup-shapes .circle { border-radius: 50%; background: color-mix(in srgb, var(--accent) 55%, var(--state-selected)); animation-delay: 120ms; } +.setup-shapes .square { border-radius: var(--shape-md); background: var(--state-selected); transform: rotate(12deg); animation-delay: 180ms; } +@keyframes m3p-shape-in { from { opacity: 0; transform: scale(0.4) rotate(-40deg); } } +.setup-card { display: flex; flex-direction: column; gap: 16px; padding: 28px; border-radius: var(--shape-xl); background: var(--surface-tonal); box-shadow: var(--shadow-1); min-width: 0; } +.setup label { gap: 6px; font-size: var(--fs-md); font-weight: 500; color: var(--fg-muted); } +.setup .input { height: 48px; padding: 0 16px; border-radius: var(--shape-md); font-size: var(--fs-lg); background: var(--bg-sunken); } +.setup .input:focus { border-radius: var(--shape-sm); } +.setup .credential-warning { margin-bottom: 0; } +.setup .error { display: flex; align-items: flex-start; gap: 8px; font-size: var(--fs-md); font-weight: 500; } +.setup .error .icon { flex: 0 0 auto; } +.setup-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } +.setup-actions .btn { width: 100%; } +.setup-actions .btn.connect { height: 56px; font-size: 16px; font-weight: 600; gap: 10px; } +.setup-actions .btn.connect:active { border-radius: var(--shape-lg); } +.setup-actions .btn.tonal { height: 44px; font-size: var(--fs-md); gap: 8px; } +.setup .spin { animation: spin 1.2s linear infinite; } + +/* ---- Downloads -------------------------------------------------------------- */ +.page-downloads .offline-head { padding: 0 var(--page-gutter) 10px var(--page-pad); } + +/* Rows of the Downloads and Filters lists: grouped rounded list items. */ +.pin-row { + display: grid; align-items: center; grid-template-columns: 56px minmax(0, 1fr) auto auto; gap: 16px; padding: 10px 12px 10px 10px; margin-top: 2px; + background: var(--surface-tonal-sunken); border-radius: var(--shape-xs); + transition: background-color var(--dur-short) var(--ease-emphasized); +} +:is(.page-downloads, .page-filters) .pin-row:not(.pin-row + .pin-row) { margin-top: 0; border-top-left-radius: var(--shape-xl); border-top-right-radius: var(--shape-xl); } +:is(.page-downloads, .page-filters) .pin-row:not(:has(+ .pin-row)) { border-bottom-left-radius: var(--shape-xl); border-bottom-right-radius: var(--shape-xl); } +:is(.page-downloads, .page-filters) .pin-row:hover { background-image: linear-gradient(var(--bg-hover), var(--bg-hover)); } +:is(.page-downloads, .page-filters) .pin-row .link-button { font-size: 14px; font-weight: 600; } +.page-downloads .pin-row .art { width: 56px; height: 56px; border-radius: var(--shape-md); transition: border-radius var(--dur-spring-long) var(--spring-fast); } +.page-downloads .pin-row:hover .art { border-radius: var(--shape-xl); } +.page-downloads .pin-row .small.muted { margin-top: 2px; } +.page-downloads .pin-row .badge { text-transform: capitalize; } +/* Download progress: M3 linear progress, rounded ends, a gap, a stop dot. */ +.page-downloads .pin-row .progress { height: 6px; display: flex; background: none; border-radius: 0; overflow: visible; margin-top: 8px !important; max-width: 420px; } +.page-downloads .pin-row .progress > div { flex: 0 1 auto; border-radius: var(--shape-full); } +.page-downloads .pin-row .progress::after { + content: ""; flex: 1 1 0; min-width: 6px; margin-left: 4px; border-radius: var(--shape-full); + background: radial-gradient(circle at calc(100% - 3px) 50%, var(--accent) 1.5px, transparent 2px), var(--state-selected); +} + +/* ---- Filters list ------------------------------------------------------------ */ +.page-filters .filter-row { padding-left: 12px; } +/* Built-ins have no delete button: keep their Play buttons in line with the others'. */ +.page-filters .filter-row > :last-child { min-width: 28px; } +.page-filters .filter-avatar { + display: grid; place-items: center; width: 44px; height: 44px; border-radius: var(--shape-md); background: var(--state-selected); color: var(--accent-text); + transition: border-radius var(--dur-spring-long) var(--spring-fast), transform var(--dur-spring-long) var(--spring-bouncy); +} +.page-filters .filter-row:hover .filter-avatar { border-radius: 50%; transform: rotate(-8deg); } +.page-filters .filter-row .link-button .small { margin-top: 2px; font-weight: 400; } + +/* ---- Filter builder ------------------------------------------------------------ */ +.page-filter-builder .filter-builder { grid-template-columns: minmax(420px, 1fr) 320px; gap: 28px; align-items: start; } +.page-filter-builder .filter-builder > .stack { gap: 16px; } +.page-filter-builder .capability-chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px 0 10px; border-radius: var(--shape-sm); font-size: var(--fs-md); font-weight: 500; line-height: 1.2; } +.page-filter-builder .rules { gap: 10px; } +/* Rule groups are tonal cards; a nested group flips to the pane surface so the nesting reads. */ +.page-filter-builder .rule-group { border: 0; border-radius: var(--shape-xl); padding: 16px; gap: 10px; background: var(--surface-tonal-sunken); } +.page-filter-builder .rule-group :is(.input, .m3-select) { background: var(--bg-elev); } +.page-filter-builder .rule-group .rule-group { background: var(--bg-elev); border-radius: var(--shape-lg); box-shadow: inset 0 0 0 1px var(--border); } +.page-filter-builder .rule-group .rule-group :is(.input, .m3-select) { background: var(--bg-sunken); } +.page-filter-builder .rule-group .group-head { gap: 8px; flex-wrap: wrap; } +.page-filter-builder .rule-group .group-head .m3-select { font-weight: 600; } +/* "Add rule" / "Add group" are assist chips: small corners, an outline, squarer still when pressed. */ +.page-filter-builder .group-head .btn.sm { padding: 0 12px 0 8px; border-radius: var(--shape-sm); background: transparent; border: 1px solid var(--border-strong); } +.page-filter-builder .group-head .btn.sm:hover { background: var(--bg-hover); } +.page-filter-builder .group-head .btn.sm:active { border-radius: var(--shape-xs); } +.page-filter-builder .rule { gap: 8px; } +.page-filter-builder .rule > .input:not([type]) { flex: 1 1 160px; min-width: 0; } +.page-filter-builder .filter-builder > .stack > .row { flex-wrap: wrap; gap: 10px 16px; } +.page-filter-builder .rule .btn.icon.sm { width: 32px; height: 32px; } +/* The sample: a tonal side card. */ +.page-filter-builder .filter-sample { background: var(--surface-tonal-sunken); border-radius: var(--shape-xl); padding: 4px 8px 12px; min-width: 0; } +.page-filter-builder .filter-sample .card-title { padding: 16px 12px 0; margin-bottom: 10px; } +.page-filter-builder .filter-sample .qrow { height: 48px !important; margin: 0; border-radius: var(--shape-md); padding: 0 10px; } +.page-filter-builder .filter-sample .qrow .art { width: 36px; height: 36px; border-radius: var(--shape-sm); } + +/* ---- Stats ------------------------------------------------------------------ */ +.page-stats .stats-stack { gap: 16px; } +.page-stats .stat-heroes { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; } +.page-stats .stat-hero { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 22px 24px 20px; border-radius: var(--shape-xl); background: var(--surface-tonal-sunken); min-width: 0; } +.page-stats .stat-hero:first-child { background: var(--state-selected); } +.page-stats .stat-hero .stat-icon { position: absolute; top: 18px; right: 18px; width: 44px; height: 44px; padding: 10px; border-radius: var(--shape-md); background: var(--bg-elev); color: var(--accent-text); } +.page-stats .stat-hero:nth-child(2) .stat-icon { border-radius: 50%; } +.page-stats .stat-label { font-size: var(--fs-md); font-weight: 600; color: var(--fg-muted); } +.page-stats .stat-label::first-letter { text-transform: uppercase; } +.page-stats .stat-figure { font-size: 56px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; font-variant-numeric: tabular-nums; } +.page-stats .stat-unit { font-size: 28px; font-weight: 700; letter-spacing: -0.01em; color: var(--fg-muted); } +.page-stats .stat-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.page-stats .bars { gap: 4px; height: 140px; } +.page-stats .bars div { border-radius: var(--shape-full); min-height: 8px; opacity: 1; background: var(--accent); transition: background-color var(--dur-short) var(--ease-emphasized); } +.page-stats .bars div:hover { background: var(--accent-text); } +.page-stats .bar-labels { gap: 4px; font-size: var(--fs-xs); margin-top: 6px; color: var(--fg-muted); } +.page-stats .card .plain-list { display: flex; flex-direction: column; gap: 2px; margin: 0 -8px; } +.page-stats .qrow { height: 48px; margin: 0; padding: 0 8px; border-radius: var(--shape-md); gap: 12px; } +.page-stats .qrow .art { width: 36px; height: 36px; border-radius: var(--shape-sm); background: var(--bg-elev); } +.page-stats .qrow .art.round { border-radius: 50%; } +.page-stats button.qrow { transition: border-radius var(--dur-spring) var(--spring-fast); } +.page-stats button.qrow:hover { background: var(--bg-hover); } +.page-stats button.qrow:active { border-radius: var(--shape-xs); } +.page-stats .rank { flex: 0 0 22px; text-align: center; font-weight: 700; color: var(--fg-muted); font-variant-numeric: tabular-nums; } +.page-stats li:first-child > .rank, .page-stats li:first-child > .qrow > .rank { color: var(--accent-text); } + +/* ---- Mini player window: compact, with a morphing play button --------------- */ +.mini .art-wrap { width: 104px; padding: 8px 0 8px 8px; } +.mini .art { border-radius: var(--shape-md); } +.mini .body { padding: 8px 10px 6px 12px; } +.mini .title { font-weight: 700; } +.mini .btn.mini-play { width: 32px; height: 32px; background: var(--accent); color: var(--accent-fg); border-radius: 50%; transition: border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy), filter var(--dur-short) var(--ease-emphasized); } +.mini .btn.mini-play.playing { border-radius: var(--shape-sm); } +.mini .btn.mini-play:hover { background: var(--accent); filter: brightness(1.08); } + +/* ---- Reduced motion: no spatial feedback here either ------------------------ */ +@media (prefers-reduced-motion: reduce) { + .page-filters .filter-row:hover .filter-avatar, .page-settings .settings-nav .nav-item:hover .icon, .setup-shapes .shape { transform: none !important; } + .setup-shapes .square { transform: rotate(12deg) !important; } +} + +/* ---- Forced colours ----------------------------------------------------------- */ +@media (forced-colors: active) { + .page-settings .settings-nav .nav-item.active { outline: 2px solid Highlight; } + .page-settings .setting-row, .page-settings .order-item, .page-settings .shortcut-row, :is(.page-downloads, .page-filters) .pin-row, .page-stats .stat-hero, .page-filter-builder .filter-sample, .setup-card { border: 1px solid CanvasText; } + .page-settings .cache-bar::after, .page-downloads .pin-row .progress::after { forced-color-adjust: none; background: Canvas; border: 1px solid CanvasText; } + .page-settings .cache-bar { border: 0; } + .page-downloads .pin-row .progress { border: 0; } + .page-stats .bars div:hover { background: Highlight; } + .setup-shapes { display: none; } + .mini .btn.mini-play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; border: 1px solid ButtonText; } +} + +/* ---- Narrow windows and 200% zoom --------------------------------------------- */ +@media (max-width: 900px) { + .page-downloads .offline-head { padding: 0 var(--page-gutter) 8px var(--page-pad); flex-wrap: wrap; } + .page-settings .settings { grid-template-columns: minmax(0, 1fr); } + .page-settings .settings-nav { flex-direction: row; flex-wrap: wrap; gap: 6px; padding: 12px 16px 4px; border-bottom: 0; } + .page-settings .settings-nav .nav-item { height: 36px; padding: 0 14px 0 10px; gap: 8px; font-size: var(--fs-md); background: var(--bg-sunken); } + .page-settings .settings-nav .nav-item .icon { width: 18px; height: 18px; } + .page-settings .settings-body { padding: 16px var(--page-gutter) 28px; } + .page-settings .setting-row { padding: 12px 16px; gap: 10px; } + .page-settings .setting-row .control { justify-content: flex-start; } + /* A lone switch stays beside its label. */ + .page-settings .setting-row:has(> .control > .m3-switch:only-child) { grid-template-columns: minmax(0, 1fr) auto; } + .page-settings .shortcut-row { padding-left: 16px; grid-template-columns: minmax(0, 1fr) auto auto; } + .page-settings .shortcut-row > :last-child { grid-column: auto; } + .page-settings .shortcut-row .rec { min-width: 0; } + .pin-row { grid-template-columns: 56px minmax(0, 1fr) auto; gap: 12px; } + .page-filter-builder .filter-builder { grid-template-columns: minmax(0, 1fr); } + .page-stats .stat-figure { font-size: 44px; } + .setup { padding: 16px; } + .setup form { grid-template-columns: minmax(0, 1fr); gap: 24px; } + .setup .brand { font-size: 56px; } + .setup .setup-title { font-size: 22px; } + .setup-card { padding: 20px; } +} diff --git a/desktop/src/renderer/styles/m3-shell.css b/desktop/src/renderer/styles/m3-shell.css new file mode 100644 index 0000000..7466e5a --- /dev/null +++ b/desktop/src/renderer/styles/m3-shell.css @@ -0,0 +1,240 @@ +/* m3-shell: Material 3 Expressive pass (loaded after global.css, before now-playing.css). + The app shell: floating panes, the navigation drawer/rail, the top bar and + its search, the side panel, menus, the palette, dialogs, snackbars and the + page-enter transition. Page contents live in m3-library.css / m3-pages.css. + + Colour rules: text only ever sits on --bg, --bg-elev, --bg-sunken or the + contrast-checked accent tints (--accent-soft, --surface-tonal, --selection, + --state-selected). Spatial motion uses the springs, fades the emphasised + easings; global.css's reduced-motion block switches all of it off. */ + +:root { --topbar-h: 56px; --pane-gap: 8px; --pane-radius: var(--shape-xl); } + +/* ---- Floating panes ------------------------------------------------------ + The window is the plain surface; the content and the side panel are + rounded surface-container panes floating on it with an 8px gutter. The + gutter left of the content belongs to the sidebar column, so the content + pane still starts exactly where the sidebar ends. */ +.app-body { padding: 0 var(--pane-gap) var(--pane-gap) 0; } +.content { + background: var(--bg-elev); border-radius: var(--pane-radius); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--border) 60%, transparent); +} + +/* Each navigation mounts the new page (Router keys it), which rises into place + on a spring. Transform only: the page is never drawn part-transparent, so its + text has full contrast from the first frame. */ +.content > * { animation: page-enter var(--dur-spring-long) var(--spring-fast); } +@keyframes page-enter { from { transform: translateY(14px); } to { transform: none; } } + +/* ---- Navigation drawer / rail -------------------------------------------- */ +.sidebar { background: transparent; border-right: 0; padding-right: var(--pane-gap); } +.sidebar-scroll { padding: 2px 4px 12px 10px; scrollbar-gutter: stable; } +/* The foot's right edge leaves room for the list's scrollbar gutter, so its pill is as wide as the ones above. */ +.sidebar-foot { border-top: 0; padding: 4px 12px 2px 10px; } +.sidebar .section-title { + padding: 18px 16px 6px; font-size: var(--fs-md); font-weight: 700; letter-spacing: 0; text-transform: none; color: var(--fg-muted); +} +.sidebar .sidebar-scroll > .section-title:first-child { padding-top: 6px; } +/* Pill items; the current one is a filled tonal indicator that springs open + behind a filled icon. The pill is drawn by ::before so it can grow in. */ +.nav-item { height: 36px; padding: 0 16px; gap: 12px; position: relative; isolation: isolate; } +.nav-item + .nav-item { margin-top: 2px; } +.nav-item.active, .nav-item.active:hover, .nav-item.active:active { background: transparent; font-weight: 700; } +.nav-item.active::before { + content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: var(--selection); + animation: nav-indicator var(--dur-spring-long) var(--spring-bouncy); transform-origin: 30% 50%; + transition: background-color var(--dur-short) var(--ease-emphasized); +} +.nav-item.active:hover::before { background: var(--state-selected); } +@keyframes nav-indicator { from { transform: scaleX(0.45); } to { transform: none; } } +.nav-item .icon { opacity: 1; color: var(--fg-muted); transition: transform var(--dur-spring) var(--spring-bouncy), color var(--dur-short) var(--ease-emphasized); } +.nav-item:hover .icon { color: var(--fg); } +.nav-item.active .icon { color: var(--accent-text); transform: scale(1.08); } +.nav-item:active .icon { transform: scale(0.9); } +/* A slim scrollbar that only shows while the pointer is over the list. */ +.sidebar-scroll::-webkit-scrollbar { width: 8px; } +.sidebar-scroll::-webkit-scrollbar-thumb { background: transparent; background-clip: padding-box; border: 2px solid transparent; } +.sidebar-scroll:hover::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--fg) 18%, transparent); background-clip: padding-box; } +/* The splitter sits in the gutter between the drawer and the content pane. */ +.sidebar > .resize-handle { right: 1px; } + +/* ---- Top bar ------------------------------------------------------------ */ +.topbar { border-bottom: 0; background: var(--bg); padding: 0 8px 0 12px; gap: 6px; } +.topbar > .grow { display: none; } +.nav-buttons { gap: 4px; } +.topbar .btn.icon { width: 36px; height: 36px; } +/* A big filled pill search bar, centred between the history buttons and the + status/window controls. */ +.search { width: 480px; max-width: 46vw; margin: 0 auto; } +.search .input { + height: 44px; padding-left: 48px; padding-right: 20px; font-size: var(--fs-lg); background: var(--bg-elev); border-color: transparent; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--border) 60%, transparent); + transition: box-shadow var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), background-color var(--dur-short) var(--ease-emphasized); +} +.search .input:hover { box-shadow: var(--shadow-1); } +.search .input:focus { border-color: transparent; box-shadow: var(--shadow-2); } +.search .input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; } +/* Open results: the bar's bottom corners square off toward the sheet below. */ +.search .input[aria-expanded="true"] { border-radius: 22px 22px var(--shape-md) var(--shape-md); } +.search > .icon { left: 18px; width: 20px; height: 20px; color: var(--fg-muted); } +.search-popover { + top: calc(100% + 6px); left: 0; right: 0; width: auto; border: 0; border-radius: var(--shape-md) var(--shape-md) var(--pane-radius) var(--pane-radius); + box-shadow: var(--shadow-2), var(--shadow); padding: 8px; transform-origin: top center; +} +.search-popover.fade-in { animation: sheet-drop var(--dur-spring) var(--spring-fast); } +@keyframes sheet-drop { from { opacity: 0; transform: translateY(-6px) scaleY(0.9); } to { opacity: 1; transform: none; } } +.search-section { padding: 2px 0; } +.result-row { height: 44px; gap: 12px; padding: 0 12px 0 6px; border-radius: var(--shape-full); transition: background-color var(--dur-short) var(--ease-emphasized); } +.result-row .art { width: 32px; height: 32px; border-radius: var(--shape-sm); } +.result-row .kind { width: auto; min-width: 56px; } +.search-divider { margin: 8px 12px; } + +/* Window controls: round state layers; close turns into a danger pill. */ +.window-controls { gap: 2px; margin-left: 6px; } +.window-controls .btn { width: 40px; height: 32px; border-radius: var(--shape-full); } +.window-controls .btn:active, .window-controls .btn.icon:active { border-radius: var(--shape-sm); } +.window-controls .btn.close:hover { background: var(--danger); color: var(--danger-fg); } + +/* Jobs popover: a rounded surface sheet under its button. */ +.jobs-btn .dot { top: 7px; right: 7px; width: 8px; height: 8px; box-shadow: 0 0 0 2px var(--bg); } +.popover { + top: calc(100% + 8px); width: 400px; border: 0; border-radius: var(--pane-radius); padding: 12px 8px; + box-shadow: var(--shadow-2), var(--shadow); +} +.popover .section-title { font-size: var(--title-card); font-weight: 750; letter-spacing: -0.01em; text-transform: none; color: var(--fg); padding: 4px 12px 10px; } +.popover .section-title.row { font-size: var(--fs-lg); padding-top: 14px; } +.popover .item { padding: 10px 12px; gap: 6px; border-radius: var(--shape-lg); } +.popover .item + .item { border-top: 0; box-shadow: 0 -1px 0 var(--border); } +.popover .progress { height: 6px; } + +/* ---- Side panel --------------------------------------------------------- + Queue and lyrics are two stacked floating cards; the divider between them + is the gutter itself. */ +.right-panel { background: transparent; border-left: 0; margin-left: var(--pane-gap); } +.right-panel .pane { + background: var(--bg-elev); border-radius: var(--pane-radius); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--border) 60%, transparent); +} +.right-panel .pane + .pane { margin-top: var(--pane-gap); } +.right-panel .pane.collapsed { flex: 0 0 48px !important; } +.right-panel > .resize-handle:not(.h) { left: -7px !important; } +.right-panel .resize-handle.h { top: 0; height: var(--pane-gap); flex: 0 0 var(--pane-gap); } +.right-panel .resize-handle.h::after { inset: 2px calc(50% - 24px); } +.pane-head { height: 48px; padding: 0 8px 0 10px; border-bottom: 0; gap: 4px; } +.right-panel .pane-head .btn.icon.sm { width: 32px; height: 32px; } +.pane-title { font-size: var(--fs-lg); font-weight: 800; letter-spacing: -0.01em; padding-left: 6px; } +/* Queue / Recent: a connected pill segment; the selected half fills tonally + and squares off slightly on the inside edge. */ +.pane-head .tabs { gap: 2px; padding: 3px; border-radius: var(--shape-full); background: var(--bg-sunken); } +.pane-head .tab { + height: 30px; padding: 0 16px; font-size: var(--fs-md); font-weight: 600; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.pane-head .tab.active, .pane-head .tab.active:hover { background: var(--state-selected); color: var(--fg); font-weight: 700; } +.pane-head .tab.active:first-child { border-radius: var(--shape-full) var(--shape-sm) var(--shape-sm) var(--shape-full); } +.pane-head .tab.active:last-child { border-radius: var(--shape-sm) var(--shape-full) var(--shape-full) var(--shape-sm); } +/* Queue rows inside the side panel (the fullscreen player styles its own). */ +.right-panel .queue-section { + background: var(--bg-elev); color: var(--fg); font-size: var(--fs-md); font-weight: 800; letter-spacing: 0; text-transform: none; padding: 8px 16px 4px; +} +.right-panel .qrow { border-radius: var(--shape-md); margin: 0 8px; height: 44px; padding: 0 10px 0 6px; transition: background-color var(--dur-short) var(--ease-emphasized); } +.right-panel .qrow .art { width: 34px; height: 34px; border-radius: var(--shape-sm); background: var(--bg-sunken); } +.right-panel .qrow.current { border-radius: var(--shape-lg); } +.right-panel .qrow.current .t1 { font-weight: 700; } +.sq-row { margin: 0 8px; padding: 8px 8px; border-radius: var(--shape-lg); transition: background-color var(--dur-short) var(--ease-emphasized); } +.sq-row .art { width: 44px; height: 44px; border-radius: var(--shape-md); background: var(--bg-sunken); } +.right-panel .lyrics-static { padding: 8px 20px 16px; } +/* The queue's per-section "Clear" stays a compact text button beside its heading. */ +.right-panel .queue-clear.btn.sm { height: 28px; padding: 0 12px; font-size: var(--fs-sm); } +/* The lyrics tools float as a small pill over the lyrics; their buttons stay compact. */ +.lyrics-tools .btn.sm:not(.icon) { height: 24px; padding: 0 8px; font-size: var(--fs-xs); } + +/* ---- Menus -------------------------------------------------------------- */ +.menu { border: 0; border-radius: var(--shape-lg); padding: 8px; min-width: 220px; box-shadow: var(--shadow-2), var(--shadow); max-height: calc(100vh - 12px); overflow-y: auto; } +.menu.fade-in { animation: menu-in var(--dur-spring) var(--spring-fast); } +@keyframes menu-in { from { opacity: 0; transform: scale(0.9, 0.8); } to { opacity: 1; transform: none; } } +.menu .mi { + height: 32px; padding: 0 12px; gap: 12px; border-radius: var(--shape-sm); + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.menu .mi:hover, .menu .mi.active { border-radius: var(--shape-full); } +.menu .mi:active { border-radius: var(--shape-xs); } +.menu .sep { margin: 6px 12px; } + +/* ---- Command palette ----------------------------------------------------- */ +.overlay { background: rgba(0, 0, 0, 0.4); } +.palette { width: 640px; border: 0; border-radius: 32px; padding: 10px; box-shadow: var(--shadow-2), var(--shadow); } +.palette.fade-in, .dialog.fade-in { animation: sheet-spring var(--dur-spring-long) var(--spring-fast); } +@keyframes sheet-spring { from { opacity: 0; transform: translateY(-14px) scale(0.92); } 40% { opacity: 1; } to { opacity: 1; transform: none; } } +.palette .input { height: 52px; font-size: 17px; padding: 0 22px; } +.palette .list { padding-top: 8px; } +.palette .group { text-transform: none; letter-spacing: 0; font-size: var(--fs-sm); font-weight: 700; color: var(--fg-muted); padding: 12px 16px 6px; } +.palette .pi { + height: 42px; padding: 0 16px 0 12px; gap: 12px; border-radius: var(--shape-md); + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.palette .pi.active { border-radius: var(--shape-full); } +.palette .pi .art { width: 30px; height: 30px; border-radius: var(--shape-sm); } + +/* ---- Dialogs ------------------------------------------------------------- */ +.dialog { width: 460px; border: 0; border-radius: 32px; padding: 28px; gap: 16px; box-shadow: var(--shadow-2), var(--shadow); } +.dialog-title { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; } +.dialog .row > .art { width: 64px; height: 64px; border-radius: var(--shape-lg); flex: 0 0 auto; } +.dialog .list { border: 0; background: var(--bg-sunken); border-radius: var(--shape-lg); padding: 6px; } +.dialog .list .li { + height: 40px; padding: 0 14px; border-radius: var(--shape-full); + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.dialog .list .li:active { border-radius: var(--shape-sm); } +.dialog .buttons { margin-top: 8px; } +.dialog .buttons .btn { height: 40px; padding: 0 20px; } + +/* ---- Snackbars ----------------------------------------------------------- */ +.toasts { bottom: calc(var(--player-h) + 20px); } +.toast { min-height: 48px; padding: 6px 6px 6px 20px; border-radius: var(--shape-lg); font-weight: 500; box-shadow: var(--shadow-2), var(--shadow); } +.toast:not(:has(.btn)) { padding-right: 20px; } +.toast.fade-in { animation: toast-in var(--dur-spring-long) var(--spring-bouncy); } +.toast .btn { height: 36px; padding: 0 16px; } + +/* ---- Forced colours ------------------------------------------------------- + Borders and fills above are dropped by the system palette; restore the + system-colour edges and the current item's Highlight. */ +@media (forced-colors: active) { + .content, .right-panel .pane { border: 1px solid CanvasText; } + .menu, .dialog, .palette, .popover, .search-popover, .toast { border: 1px solid CanvasText; } + .search .input { border: 1px solid ButtonText; } + .nav-item.active, .nav-item.active:hover { forced-color-adjust: none; background: Highlight; color: HighlightText; } + .nav-item.active::before { display: none; } + .pane-head .tabs { border: 1px solid CanvasText; } + .pane-head .tab.active { outline: 2px solid Highlight; } +} + +/* ---- Narrow windows and 200% zoom ---------------------------------------- */ +@media (max-width: 900px) { + :root { --pane-gap: 6px; --pane-radius: var(--shape-lg); } + .sidebar { padding-right: var(--pane-gap); } + .sidebar-scroll, .sidebar-foot { padding: 4px 2px 4px 4px; } + /* The rail is too narrow to give a scrollbar room; it still scrolls. */ + .sidebar-scroll { scrollbar-width: none; } + .sidebar .nav-item { padding: 0; height: 36px; } + .sidebar .nav-item.active .icon { transform: none; } + .topbar { padding: 0 6px; gap: 4px; } + .topbar .btn.icon { width: 32px; height: 32px; } + .search { width: auto; max-width: none; margin: 0; } + .search .input { height: 40px; } + /* Results drop straight under the (now wide) field instead of a fixed sheet. */ + .search-popover { position: absolute; top: calc(100% + 6px); left: 0; right: 0; width: auto; } + .popover { position: absolute; top: calc(100% + 8px); left: auto; right: 0; width: min(400px, calc(100vw - 16px)); } + .window-controls .btn { width: 32px; } + /* The drawer: the two cards on a plain sheet sliding over the content. */ + .right-panel.drawer { + margin-left: 0; top: 0; right: var(--pane-gap); bottom: var(--pane-gap); background: var(--bg); padding: 6px; + border-radius: var(--shape-xl); box-shadow: var(--shadow-2), var(--shadow); + animation: drawer-in var(--dur-spring-long) var(--spring-fast); + } + /* A reveal from the right edge (clip-path, not a translate, so nothing ever overflows the window). */ + @keyframes drawer-in { from { clip-path: inset(0 0 0 35% round var(--shape-xl)); } to { clip-path: inset(0 round var(--shape-xl)); } } + .dialog { padding: 20px; } + .dialog-title { font-size: 22px; } +} diff --git a/desktop/src/renderer/styles/now-playing.css b/desktop/src/renderer/styles/now-playing.css new file mode 100644 index 0000000..da3e072 --- /dev/null +++ b/desktop/src/renderer/styles/now-playing.css @@ -0,0 +1,232 @@ +/* The fullscreen "Now playing" view and the queue timeline. Loaded after + global.css. Motion comes from lib/spring.ts through --spring-* custom + properties set on the view (springs sampled into linear() easings). + Text over the background is white or 80 % white; FluidBackground's scrim is + sized so both reach 4.5:1, and .np-fade only ever darkens. */ + +/* ---- Queue timeline (side panel and fullscreen) ------------------------- */ +.queue-section { display: flex; align-items: center; gap: 8px; min-height: 30px; } +.queue-section-text { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.queue-section-sub { text-transform: none; letter-spacing: 0; font-weight: 400; color: var(--fg-muted); font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queue-scroll { position: relative; } +/* Clear buttons sit outside the listbox, anchored onto their section's sticky header. */ +.queue-clear { position: absolute; z-index: 2; top: anchor(center); right: anchor(right); translate: -8px -50%; text-transform: none; letter-spacing: 0; height: 22px; border-radius: 11px; font-weight: 500; } +.queue-more { padding: 8px 12px; color: var(--fg-muted); font-size: var(--fs-xs); } +.qrow .grip { display: inline-flex; flex: 0 0 auto; } +.qrow .grip-space { width: 14px; flex: 0 0 auto; } + +/* ---- Fullscreen player --------------------------------------------------- */ +.fullscreen.np { display: block; overflow: hidden; } +.np-fade { position: absolute; inset: 0; z-index: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.12) 40%, rgba(0, 0, 0, 0.62) 100%); } +.np-stage { position: relative; z-index: 1; height: 100%; width: min(calc(100% - 48px), 680px); margin: 0 auto; padding: 18px 0 22px; display: flex; flex-direction: column; gap: 14px; } +.np[data-mode="queue"] .np-stage, .np[data-mode="lyrics"] .np-stage, .np[data-mode="about"] .np-stage { width: min(calc(100% - 48px), 760px); } + +.np-head { display: flex; align-items: center; gap: 12px; min-height: 44px; flex: 0 0 auto; } +.np-source { display: flex; flex-direction: column; min-width: 0; flex: 1; } +.np-kicker { font-size: var(--fs-sm); color: var(--fg-muted); } +.np-source-name { font-size: var(--fs-lg); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.np-main { flex: 1 1 auto; min-height: 0; container-type: size; display: flex; } +.np-art-slot { width: 100%; height: 100%; display: grid; place-items: center; } +.np-art { width: min(100cqw, 100cqh, 46vh); aspect-ratio: 1; border-radius: 28px; overflow: hidden; box-shadow: 0 28px 70px rgba(0, 0, 0, 0.55), 0 4px 14px rgba(0, 0, 0, 0.3); background: rgba(255, 255, 255, 0.06); } +.np-art-img { display: block; width: 100%; height: 100%; object-fit: cover; } +.np-art-img.placeholder { display: flex; align-items: center; justify-content: center; color: var(--fg-muted); background: rgba(255, 255, 255, 0.06); } +.np-pane { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; animation: np-pane-in var(--spring-spatial-ms, 400ms) var(--spring-spatial, var(--ease)); } +@keyframes np-pane-in { from { opacity: 0; transform: translateY(18px) scale(0.98); } } + +.np-controls { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 14px; flex: 0 0 auto; min-width: 0; } +.np-info { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; min-width: 0; } +.np-thumb { flex: 0 0 auto; width: 72px; height: 72px; padding: 0; border: 0; border-radius: 12px; overflow: hidden; background: rgba(255, 255, 255, 0.06); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); color: inherit; } +.np-thumb:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: 12px; } +.np-text { flex: 1; min-width: 0; text-align: left; } +.np-text .t1 { margin: 0; font-size: 26px; line-height: 1.2; font-weight: 700; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np-text .t2 { font-size: 16px; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; } +.np-link { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; border-radius: 4px; } +.np-link:hover { text-decoration: underline; text-underline-offset: 3px; } +.np-actions { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; } + +/* Buttons: circles with a state layer; pressing squashes them a little, springing back. */ +.np-icon-btn, .np-skip, .np-play, .np-tonal, .np-mode, .np-chip, .np-thumb { + -webkit-appearance: none; appearance: none; font: inherit; cursor: default; + transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), border-radius var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), width var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), background-color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)), color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)); +} +.np-icon-btn:active, .np-skip:active, .np-tonal:active, .np-mode:active, .np-chip:active, .np-thumb:active { transform: scale(0.9); } +.np-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 22px; border: 0; background: transparent; color: #fff; padding: 0; } +.np-icon-btn.sm { width: 32px; height: 32px; } +.np-icon-btn:hover { background: rgba(255, 255, 255, 0.12); } +.np-icon-btn.heart.on { color: #ff9aae; } + +/* Wavy seek bar (WavySeek.tsx draws the wave; the geometry lives there). */ +.np-seek { flex: 0 0 auto; display: flex; flex-direction: column; gap: 0; } +.np-seek-bar { position: relative; height: 40px; touch-action: none; border-radius: 12px; } +.np-seek-bar[aria-disabled="true"] { opacity: 0.6; } +.np-seek-bar:focus-visible { outline-offset: 2px; border-radius: 12px; } +.np-seek-bar svg { display: block; overflow: visible; } +.np-seek-bar .played { fill: none; stroke: #fff; stroke-width: 6px; stroke-linecap: round; stroke-linejoin: round; } +.np-seek-bar .rest { stroke: rgba(255, 255, 255, 0.28); stroke-width: 6px; stroke-linecap: round; } +.np-seek-bar .stop { fill: #fff; } +/* The thumb stretches (a spring) while hovered and more while held. */ +.np-seek-bar .thumb { fill: #fff; transform-box: fill-box; transform-origin: center; transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)); } +.np-seek-bar:hover .thumb { transform: scaleY(1.12); } +.np-seek-bar.dragging .thumb { transform: scale(0.7, 1.25); } +.np-seek-bar .hover-time, .np-seek-bar .value-bubble { position: absolute; bottom: 42px; transform: translateX(-50%); padding: 4px 10px; border-radius: 14px; font-size: var(--fs-sm); font-weight: 600; pointer-events: none; white-space: nowrap; font-variant-numeric: tabular-nums; } +.np-seek-bar .hover-time { background: rgba(16, 16, 20, 0.88); color: #fff; } +/* The value indicator: a filled pill that pops up over the thumb. */ +.np-seek-bar .value-bubble { background: #fff; color: #121214; animation: np-bubble var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)); transform-origin: 50% 100%; } +@keyframes np-bubble { from { transform: translateX(-50%) scale(0.4); opacity: 0; } } +.np-seek-times { display: flex; justify-content: space-between; font-size: var(--fs-sm); color: var(--fg-muted); font-variant-numeric: tabular-nums; margin-top: -4px; } + +/* Volume (wide layout): a thick rounded track with a gap either side of a bar thumb. */ +.np-volume { display: none; align-items: center; gap: 10px; flex: 0 0 auto; } + +/* Transport: the play button morphs between a pill (paused) and a rounded square (playing). */ +.np-transport { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 0 0 auto; } +/* Shuffle and repeat flank the transport: quiet circles that fill (and square off) when on. */ +.np-tonal.np-flank { width: 48px; height: 48px; border-radius: 24px; background: transparent; } +.np-tonal.np-flank:hover { background: rgba(255, 255, 255, 0.14); } +.np-tonal.np-flank.on { background: rgba(255, 255, 255, 0.92); border-radius: 16px; } +/* Autoplay, device and sleep share a row with volume (wide windows). */ +.np-row2 { display: flex; align-items: center; justify-content: center; gap: 16px; flex: 0 0 auto; flex-wrap: wrap; } +.np-skip { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 32px; border: 0; background: transparent; color: #fff; padding: 0; } +.np-skip:hover { background: rgba(255, 255, 255, 0.12); border-radius: 22px; } +.np-play { display: inline-flex; align-items: center; justify-content: center; height: 80px; border: 0; padding: 0; background: #fff; color: #121214; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); } +.np-play.paused { width: 104px; border-radius: 40px; } +.np-play.playing { width: 80px; border-radius: 26px; } +.np-play:hover { background: #f1f1f4; } +.np-play:active { transform: scale(0.92); } +.np-play:focus-visible { outline: 3px solid #fff; outline-offset: 3px; border-radius: 40px; } +.np-play .spin { animation: spin 1.2s linear infinite; } + +/* Playback options: tonal buttons; on, they fill and square off a little. */ +.np-options { display: flex; align-items: center; justify-content: center; gap: 10px; flex: 0 0 auto; flex-wrap: wrap; } +.np-tonal { display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 40px; border-radius: 20px; border: 0; padding: 0; background: rgba(255, 255, 255, 0.12); color: #fff; } +.np-tonal:hover { background: rgba(255, 255, 255, 0.2); } +.np-tonal.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: 12px; } +.np-tonal.on:hover { background: #fff; } + +/* Lyrics / Queue / About */ +.np-modes { display: flex; align-items: center; justify-content: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; } +.np-mode { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 20px 0 16px; border-radius: 22px; border: 1px solid rgba(255, 255, 255, 0.34); background: transparent; color: #fff; font-size: var(--fs-lg); font-weight: 500; } +.np-mode:hover { background: rgba(255, 255, 255, 0.12); } +.np-mode.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-color: transparent; border-radius: 14px; } +.np-mode.on:hover { background: #fff; } + +/* Chips (queue header, About) */ +.np-chip { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 12px; border-radius: 17px; border: 0; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: var(--fs-md); font-weight: 500; } +.np-chip:hover { background: rgba(255, 255, 255, 0.2); } +.np-chip.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: 10px; } + +/* Panes */ +.np .empty { color: var(--fg-muted); } +.np-pane-lyrics .lyrics-pane, .np-pane-lyrics .amll-host { height: 100%; } +.np-pane-lyrics .amll-host.large { mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 48px), transparent 100%); } +.np-pane-lyrics .lyrics-static { font-size: 22px; } +.np .queue-wrap { height: 100%; } +.np .queue-header { display: flex; gap: 8px; padding: 0 0 12px; flex: 0 0 auto; flex-wrap: wrap; } +.np .queue-scroll { border-radius: 20px; } +.np .queue-list { padding-bottom: 60cqh; } +.np .queue-section { background: rgba(12, 12, 16, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 14px; padding: 8px 12px; margin: 0 0 4px; font-size: var(--fs-sm); text-transform: none; letter-spacing: 0; color: #fff; } +.np .queue-section-sub { color: rgba(255, 255, 255, 0.8); } +.np .queue-group.history .qrow { opacity: 0.85; } +.np .qrow { height: 60px; border-radius: 16px; margin: 0 0 2px; padding: 0 10px; gap: 12px; } +.np .qrow .art { width: 44px; height: 44px; border-radius: 10px; } +.np .qrow .t1 { font-size: var(--fs-lg); font-weight: 500; } +.np .qrow .t2 { font-size: var(--fs-sm); } +.np .qrow.current { background: rgba(255, 255, 255, 0.14); } +.np .qrow .grip { opacity: 0.55; } +.np .qrow:hover .grip { opacity: 1; } +.np .queue-clear { color: #fff; } + +.np-about { height: 100%; min-height: 0; } +.np-about-scroll { height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; border-radius: 24px; } +.np-card { background: rgba(0, 0, 0, 0.28); border-radius: 24px; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; align-items: flex-start; } +.np-card-title { margin: 0; font-size: var(--fs-lg); font-weight: 700; } +.np-facts { margin: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px 20px; } +.np-fact { min-width: 0; } +.np-fact.wide { grid-column: 1 / -1; } +.np-fact dt { font-size: var(--fs-sm); color: var(--fg-muted); margin: 0 0 2px; } +.np-fact dd { margin: 0; font-size: var(--fs-lg); overflow-wrap: anywhere; } +.np-muted { color: var(--fg-muted); } +.np-card .plain-list { width: 100%; } +.np .related-row { display: flex; gap: 12px; padding: 8px 10px; align-items: center; border-radius: 14px; } +.np .related-row:hover { background: rgba(255, 255, 255, 0.1); } +.np .related-row .art { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; } +.np .related-row .why { font-size: var(--fs-xs); color: var(--fg-muted); } + +/* ---- Wide windows: a desktop layout, not a stretched phone ----------------- + Artwork mode: a modest cover beside the controls column, centred together. + Pane modes: the pane fills the left and the cover stacks, large, on top of + the controls on the right. The cover flies between the two (FLIP) and the + controls column glides to its new place. */ +@media (min-width: 1100px) and (min-height: 620px) { + .np-stage, .np[data-mode] .np-stage { width: min(calc(100% - 96px), 1520px); display: grid; grid-template-columns: minmax(0, 1fr) clamp(380px, 30vw, 460px); grid-template-rows: auto minmax(0, 1fr); column-gap: clamp(40px, 5vw, 96px); row-gap: 20px; padding: 28px 0 36px; } + .np[data-mode="art"] .np-stage { grid-template-columns: clamp(300px, min(50vh, 38vw), 560px) clamp(380px, 30vw, 460px); justify-content: center; } + .np-head { grid-column: 1 / -1; } + .np-main { grid-column: 1; grid-row: 2; } + .np-controls { grid-column: 2; grid-row: 2; align-self: center; gap: 18px; } + .np-art { border-radius: 32px; } + .np-art-slot { place-items: center; } + .np-info { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "thumb" "text" "actions"; row-gap: 10px; } + /* The stacked cover: as large as the column and the window height allow. */ + .np-thumb { grid-area: thumb; width: clamp(140px, calc(100vh - 640px), 100%); height: auto; aspect-ratio: 1; border-radius: 28px; margin-bottom: 8px; box-shadow: 0 22px 56px rgba(0, 0, 0, 0.5), 0 3px 10px rgba(0, 0, 0, 0.3); } + .np-thumb:focus-visible { border-radius: 28px; } + .np-text { grid-area: text; } + .np-text .t1 { font-size: clamp(28px, 2.4vw, 40px); line-height: 1.12; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + .np-text .t2 { font-size: 18px; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + .np-actions { grid-area: actions; margin-left: -10px; } + .np-transport { justify-content: space-between; gap: 8px; } + .np-play { height: 88px; } + .np-play.paused { width: 120px; border-radius: 44px; } + .np-play.playing { width: 88px; border-radius: 28px; } + .np-skip { width: 68px; height: 68px; border-radius: 34px; } + .np-volume { display: flex; flex: 1 1 150px; min-width: 0; } + .np-row2 { justify-content: space-between; flex-wrap: nowrap; } + .np-options { flex: 0 0 auto; flex-wrap: nowrap; gap: 8px; } + .np-options .np-tonal { width: 48px; } + .np-modes { justify-content: stretch; } + .np-mode { flex: 1 1 0; justify-content: center; padding: 0 12px; } + .np-pane { background: rgba(0, 0, 0, 0.16); border-radius: 32px; padding: 20px 20px 0; } + .np-pane-lyrics { background: none; padding: 0; } + .np .queue-list { padding-bottom: 40cqh; } + + /* Pane modes: the controls condense (title and actions on one line, a + smaller transport) so the cover above them can span the column. */ + .np:not([data-mode="art"]) .np-controls { gap: 14px; } + .np:not([data-mode="art"]) .np-info { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "thumb thumb" "text actions"; column-gap: 8px; row-gap: 28px; align-items: center; } + .np:not([data-mode="art"]) .np-thumb { width: clamp(160px, calc(100vh - 504px), 100%); margin: 0; justify-self: center; } + .np:not([data-mode="art"]) .np-text .t1 { font-size: 26px; -webkit-line-clamp: 1; } + .np:not([data-mode="art"]) .np-text .t2 { font-size: 16px; -webkit-line-clamp: 1; } + .np:not([data-mode="art"]) .np-actions { margin: 0 -8px 0 0; gap: 0; } + .np:not([data-mode="art"]) .np-actions .np-icon-btn { width: 40px; height: 40px; } + .np:not([data-mode="art"]) .np-play { height: 72px; } + .np:not([data-mode="art"]) .np-play.paused { width: 100px; border-radius: 36px; } + .np:not([data-mode="art"]) .np-play.playing { width: 72px; border-radius: 24px; } + .np:not([data-mode="art"]) .np-skip { width: 56px; height: 56px; } + .np:not([data-mode="art"]) .np-mode { height: 40px; } +} + +/* Short or narrow windows (and 200% zoom): the page scrolls rather than squashing the controls. */ +@media (max-width: 900px), (max-height: 620px) { + .fullscreen.np { overflow-y: auto; } + .np-stage { height: auto; min-height: 100%; width: min(calc(100% - 32px), 680px) !important; padding: 12px 0 16px; gap: 12px; } + .np-main { flex: 0 0 auto; height: max(200px, min(70vw, calc(100vh - 430px))); } + .np-text .t1 { font-size: 20px; } + .np-transport { gap: 16px; } + .np-mode { padding: 0 14px 0 12px; } +} + +@media (forced-colors: active) { + .np-fade { display: none; } + .np-play, .np-tonal, .np-mode, .np-chip, .np-icon-btn, .np-skip { border: 1px solid ButtonText; } + .np-play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; } + .np-tonal.on, .np-mode.on, .np-chip.on { forced-color-adjust: none; background: Highlight; color: HighlightText; border-color: Highlight; } + .np-seek-bar .played { stroke: Highlight; } + .np-seek-bar .rest { stroke: GrayText; } + .np-seek-bar .thumb, .np-seek-bar .stop { fill: CanvasText; } + .np-seek-bar .value-bubble { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; } + .np .queue-section { background: Canvas; } + .np-card { border: 1px solid CanvasText; } +} + +/* While the player is open, snackbars drop in from the top instead of covering the transport. */ +.app:has(.fullscreen.np) .toasts { bottom: auto; top: 16px; } diff --git a/desktop/src/renderer/views/AlbumDetail.tsx b/desktop/src/renderer/views/AlbumDetail.tsx index 16e39cc..d57cd3e 100644 --- a/desktop/src/renderer/views/AlbumDetail.tsx +++ b/desktop/src/renderer/views/AlbumDetail.tsx @@ -23,7 +23,7 @@ export function AlbumDetail({ id }: { id: string }) { // play button primes that track. Once per visit (a remount is a new visit). useAlbumDwellPrime(a?.id); const primer = usePlayIntent(`album:${id}`); - const play = (start = 0, shuffle = false) => a && bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "album", data: { id: a.id } }, label: a.name, sort: "default", tracks: [] }, startIndex: start, shuffle, saveOutgoing: true } } }); + const play = (start = 0, shuffle = false) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "album", data: { id } }, label: a?.name ?? "", sort: "default", tracks: [] }, startIndex: start, shuffle, saveOutgoing: true } } }); return (
a && void openContextMenu(e, { type: "albums", data: { ids: [a.id] } })}> @@ -36,15 +36,15 @@ export function AlbumDetail({ id }: { id: string }) { · {a?.year ?? ""}{a?.genre ? ` · ${a.genre}` : ""} · {t("album.tracks", { count: a?.songCount ?? 0, duration: fmtDuration(a?.durationMs ?? 0) })}
- - + + {a ? bridge().dispatch({ type: "setLoved", data: { targets: [{ type: "album", data: { id: a.id } }], loved: !a.loved } })} /> : null} {a ? bridge().dispatch({ type: "setRating", data: { targets: [{ type: "album", data: { id: a.id } }], rating: r } })} /> : null}
-
+
play(i)} playingTrackId={playing} emptyMessage={t("songs.empty")} testId="album-tracks" primer={primer} />
diff --git a/desktop/src/renderer/views/Albums.tsx b/desktop/src/renderer/views/Albums.tsx index fa98d09..ef33896 100644 --- a/desktop/src/renderer/views/Albums.tsx +++ b/desktop/src/renderer/views/Albums.tsx @@ -7,6 +7,7 @@ import { AlbumGrid, type GridItem } from "../components/AlbumGrid"; import { usePagedGrid } from "./paged"; import { loadLocal, saveLocal } from "../lib/local-settings"; import { Icon } from "../components/Icon"; +import { Select } from "../components/controls"; const SORTS: SortOrder[] = ["title", "artist", "year", "dateAdded", "rating", "playCount", "random"]; @@ -27,12 +28,14 @@ export function Albums({ artistId, genre, title }: { artistId?: string; genre?:

{title ?? t("albums.title")}

- {total} + {total}
- { setSort(e.target.value as SortOrder); saveLocal("albums.sort", e.target.value); }} data-testid="albums-sort"> {SORTS.map((s) => )} - - + + +
navigate({ view: "album", id: it.id })} onPlay={play} onNeedRange={onNeedRange} emptyMessage={t("albums.empty")} testId="albums-grid" label={title ?? t("albums.title")} /> diff --git a/desktop/src/renderer/views/ArtistDetail.tsx b/desktop/src/renderer/views/ArtistDetail.tsx index afa2ff5..3055d44 100644 --- a/desktop/src/renderer/views/ArtistDetail.tsx +++ b/desktop/src/renderer/views/ArtistDetail.tsx @@ -17,7 +17,7 @@ export function ArtistDetail({ id }: { id: string }) { const albums = useQuery(() => ({ type: "albums", data: { server_id: serverId, artist_id: id, genre: undefined, sort: "year", descending: true, page: { offset: 0, limit: 500 } } }), "albums", [id, serverId]); const top = useQuery(() => ({ type: "artistTopSongs", data: { id, count: 10 } }), "trackList", [id]); const a = artist.data; - const play = (shuffle: boolean) => a && bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "artist", data: { id: a.id } }, label: a.name, sort: "default", tracks: [] }, startIndex: 0, shuffle, saveOutgoing: true } } }); + const play = (shuffle: boolean) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "artist", data: { id } }, label: a?.name ?? "", sort: "default", tracks: [] }, startIndex: 0, shuffle, saveOutgoing: true } } }); const topList = top.data ?? []; return (
@@ -29,8 +29,8 @@ export function ArtistDetail({ id }: { id: string }) {
{a ? t("artist.albumsCount", { albums: a.albumCount, songs: a.songCount }) : ""}
{a?.biography ?
{a.biography}
: null}
- - + + {a ? bridge().dispatch({ type: "setArtistLoved", data: { artist_id: a.id, loved: !a.loved } })} /> : null}
@@ -38,13 +38,13 @@ export function ArtistDetail({ id }: { id: string }) {
{topList.length ? ( <> -

{t("artist.topSongs")}

-
+

{t("artist.topSongs")}

+
bridge().dispatch({ type: "playTracks", data: { server_id: serverId, track_ids: topList.map((x) => x.id), start_index: i, label: `${a?.name ?? ""} · ${t("artist.topSongs")}`, shuffle: false } })} playingTrackId={playing} testId="artist-top" />
) : null} -

{t("artist.albums")}

+

{t("artist.albums")}

{(albums.data?.items ?? []).map((al) => ( navigate({ view: "album", id: al.id })} onContextMenu={(e) => void openContextMenu(e, { type: "albums", data: { ids: [al.id] } })} testId="artist-album" /> diff --git a/desktop/src/renderer/views/Artists.tsx b/desktop/src/renderer/views/Artists.tsx index c69c046..1b22ef5 100644 --- a/desktop/src/renderer/views/Artists.tsx +++ b/desktop/src/renderer/views/Artists.tsx @@ -19,7 +19,7 @@ export function Artists() { const play = (it: GridItem) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "artist", data: { id: it.id } }, label: it.title, sort: "default", tracks: [] }, startIndex: 0, shuffle: true, saveOutgoing: true } } }); return (
-

{t("artists.title")}

{total}
+

{t("artists.title")}

{total}
navigate({ view: "artist", id: it.id })} onPlay={play} onNeedRange={onNeedRange} tileWidth={140} emptyMessage={t("artists.empty")} testId="artists-grid" label={t("artists.title")} />
); diff --git a/desktop/src/renderer/views/Downloads.tsx b/desktop/src/renderer/views/Downloads.tsx index f2625d4..a58779a 100644 --- a/desktop/src/renderer/views/Downloads.tsx +++ b/desktop/src/renderer/views/Downloads.tsx @@ -24,17 +24,17 @@ export function Downloads({ tab }: { tab?: string }) { const st = useQuery(() => ({ type: "storage" }), "storage", [], { static: true }); const sum = storage ?? st.data; return ( -
+

{t("downloads.title")}

- {sum ? {t("downloads.usage", { downloads: fmtBytes(sum.downloadsBytes), cache: fmtBytes(sum.cacheBytes), images: fmtBytes(sum.imagesBytes) })} : null} - +
navigate({ view: "downloads", param: k === "offline" ? "offline" : undefined }, true)} tabs={[{ key: "pins", label: t("downloads.tab.pins"), testId: "downloads-tab-pins" }, { key: "offline", label: t("downloads.tab.offline"), testId: "downloads-tab-offline" }]} /> + {sum ? {t("downloads.usage", { downloads: fmtBytes(sum.downloadsBytes), cache: fmtBytes(sum.cacheBytes), images: fmtBytes(sum.imagesBytes) })} : null}
{current === "pins" ? : }
@@ -54,7 +54,7 @@ function Pins() { }; return (
- {!list.length ? navigate({ view: "albums" })}>{t("nav.albums")}} /> : null} + {!list.length ? navigate({ view: "albums" })}>{t("nav.albums")}} /> : null} {list.map((p) => (
@@ -83,11 +83,11 @@ function AvailableOffline() { const { rows, total, onNeedRange } = usePagedTracks(fetchPage, [serverId, "offline", filter.sort, filter.descending]); const play = (startIndex: number) => bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "filter", data: { filter } }, label: t("downloads.tab.offline"), sort: filter.sort, tracks: [] }, startIndex, shuffle: false, saveOutgoing: true } } }); return ( -
+
{t("downloads.offlineHint")} {t("downloads.offlineCount", { count: total })} - +
play(i)} playingTrackId={playing} emptyMessage={t("downloads.offlineEmpty")} testId="available-offline-table" />
diff --git a/desktop/src/renderer/views/FilterBuilder.tsx b/desktop/src/renderer/views/FilterBuilder.tsx index 5831224..28f0808 100644 --- a/desktop/src/renderer/views/FilterBuilder.tsx +++ b/desktop/src/renderer/views/FilterBuilder.tsx @@ -9,6 +9,7 @@ import { bridge } from "../core/bridge"; import { Icon } from "../components/Icon"; import { filterName, isBuiltinFilter } from "../lib/filters"; import { Artwork } from "../components/Artwork"; +import { Select, Switch } from "../components/controls"; const TEXT_FIELDS: FilterField[] = ["title", "album", "artist", "albumArtist", "genre", "filePath", "fileType", "comment", "lyrics", "key", "mood"]; const NUM_FIELDS: FilterField[] = ["year", "playCount", "rating", "duration", "bitRate", "discNumber", "trackNumber", "bpm", "energy", "localPlayCount"]; @@ -71,27 +72,27 @@ export function FilterBuilder({ id }: { id: string }) { }; const localOnly = preview?.capability.localOnlyFields ?? []; return ( -
+

{existing ? filterName(existing) : t("filters.builder")}

{preview ? t("filters.count", { count: preview.count }) : } - +
-
- {preview?.capability.serverExpressible ? t("filters.serverExpressible") : t("filters.localOnly", { fields: localOnly.join(", ") })} +
+ {preview?.capability.serverExpressible ? t("filters.serverExpressible") : t("filters.localOnly", { fields: localOnly.join(", ") })}
setFilter({ ...filter, root })} root />
- +
@@ -101,8 +102,8 @@ export function FilterBuilder({ id }: { id: string }) { {existing && !isBuiltinFilter(existing.id) ? : null}
-
-
{t("filters.sample")}
+
+
{t("filters.sample")}
{(preview?.sample ?? []).map((s) => (
{s.title}
{s.artist}
))} @@ -120,10 +121,10 @@ function GroupEditor({ node, onChange, root = false }: { node: FilterNode; onCha return (
- onChange({ type: e.target.value as "all" | "any", data: children })} aria-label="Match"> - + @@ -152,8 +153,8 @@ function RuleEditor({ rule, onChange, onRemove }: { rule: FilterRule; onChange: const fields = useMemo(() => [...FilterFieldValues], []); return (
- - + + {v.type === "text" ? onChange({ ...rule, value: { type: "text", data: e.target.value } })} aria-label="Value" data-testid="rule-value" /> : null} {v.type === "number" ? onChange({ ...rule, value: { type: "number", data: Number(e.target.value) } })} aria-label="Value" style={{ width: 100 }} /> : null} {v.type === "days" ? onChange({ ...rule, value: { type: "days", data: Number(e.target.value) } })} aria-label="Days" style={{ width: 80 }} /> : null} diff --git a/desktop/src/renderer/views/Filters.tsx b/desktop/src/renderer/views/Filters.tsx index 0565215..4e146f1 100644 --- a/desktop/src/renderer/views/Filters.tsx +++ b/desktop/src/renderer/views/Filters.tsx @@ -14,21 +14,21 @@ export function Filters() { if (f) bridge().dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "filter", data: { filter: f } }, label: filterName(f), sort: f.sort, tracks: [] }, startIndex: 0, shuffle: false, saveOutgoing: true } } }); }; return ( -
+

{t("filters.title")}

-
+
- {!filters.length ? navigate({ view: "filter", id: "new" })}>{t("filters.new")}} /> : null} + {!filters.length ? navigate({ view: "filter", id: "new" })}>{t("filters.new")}} /> : null} {filters.map((f) => ( -
- +
+ - + {/* Built-ins can't be deleted (the core would only drop a saved override). */} {isBuiltinFilter(f.id) ? : }
diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index 5fcd01b..0673b6a 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -1,45 +1,149 @@ -// Fullscreen player (F): Kawarp background, large cover, transport, tabbed -// side panel Up Next / Related / Lyrics. -// A modal dialog: focus moves in on open (the close button), Tab stays inside, -// Escape closes, and focus returns to what opened it. -import { useEffect, useRef, useState } from "react"; +// The fullscreen "Now playing" view (F): a blurred, artwork-coloured +// background darkening towards the bottom; "Playing from " and a +// collapse chevron; the big artwork; title and artist • album with love, +// add-to-playlist and more; the wavy seek bar; the transport; playback +// options; and Lyrics / Queue / About toggles. +// +// Choosing Lyrics, Queue or About replaces the artwork in place with that pane +// and the artwork flies (FLIP, a spring) into a thumbnail beside the title; +// choosing it again flies it back. The choice is remembered (store +// `nowPlayingMode`), so the player reopens the way it was left. On open the +// artwork flies in from the player bar's. +// +// A modal dialog: focus moves in on open (the collapse button), Tab stays +// inside, Escape closes, and focus returns to what opened it. +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; import { t } from "@shared/strings"; -import { useApp } from "../store/app"; +import type { Track } from "@core/api"; +import { useApp, type NowPlayingMode } from "../store/app"; import { useQuery } from "../store/queries"; +import { executeAction } from "../store/actions"; import { bridge } from "../core/bridge"; import { FluidBackground } from "../components/FluidBackground"; import { Artwork } from "../components/Artwork"; import { Icon } from "../components/Icon"; -import { SeekBar } from "../components/PlayerBar"; +import { WavySeek } from "../components/WavySeek"; import { QueuePanel } from "../components/QueuePanel"; import { LyricsPane } from "../components/RightPanel"; -import { Heart, Stars } from "../components/Stars"; -import { fmtTime } from "../lib/format"; -import { Tabs, tabPanelProps } from "../components/Tabs"; +import { Stars } from "../components/Stars"; +import { Slider } from "../components/controls"; +import { openMenuFromButton } from "../components/ContextMenu"; +import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; import { trapTab, useReturnFocus } from "../lib/focus"; +import { usePrefersReducedMotion } from "../lib/media"; +import { volumeValueText } from "../lib/a11y"; +import { SPRING_EFFECTS, SPRING_FAST, SPRING_SPATIAL } from "../lib/spring"; -type Tab = "upNext" | "related" | "lyrics"; +type Pane = Exclude; +const PANES: { key: Pane; icon: string }[] = [ + { key: "lyrics", icon: "lyrics" }, + { key: "queue", icon: "queue" }, + { key: "about", icon: "info" }, +]; +/** The artwork's corner radius as laid out (the stylesheet sizes it per layout). */ +const radiusOf = (el: HTMLElement) => Number.parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0; + +/** The motion tokens the stylesheet uses, from lib/spring.ts. */ +const MOTION = { + "--spring-spatial": SPRING_SPATIAL.easing, + "--spring-spatial-ms": `${SPRING_SPATIAL.duration}ms`, + "--spring-fast": SPRING_FAST.easing, + "--spring-fast-ms": `${SPRING_FAST.duration}ms`, + "--spring-effects": SPRING_EFFECTS.easing, + "--spring-effects-ms": `${SPRING_EFFECTS.duration}ms`, +} as CSSProperties; + +/** Animate `el` from where `from` was to where it is now (FLIP), corners included. Returns where it lands. */ +function flyFrom(el: HTMLElement, from: DOMRect, fromRadius: number, toRadius: number): DOMRect { + const to = el.getBoundingClientRect(); + if (!to.width || !from.width) return to; + const sx = from.width / to.width; + const sy = from.height / to.height; + if (Math.abs(from.left - to.left) < 1 && Math.abs(from.top - to.top) < 1 && Math.abs(sx - 1) < 0.01) return to; + el.animate( + [ + { transformOrigin: "0 0", transform: `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${sx}, ${sy})`, borderRadius: `${fromRadius / sx}px / ${fromRadius / sy}px` }, + { transformOrigin: "0 0", transform: "none", borderRadius: `${toRadius}px` }, + ], + { duration: SPRING_SPATIAL.duration, easing: SPRING_SPATIAL.easing }, + ); + return to; +} export function FullscreenPlayer() { const now = useApp((s) => s.nowPlaying); const transport = useApp((s) => s.transport); const queue = useApp((s) => s.queue); + const devices = useApp((s) => s.devices); + const sleep = useApp((s) => s.sleepTimer); + const mode = useApp((s) => s.nowPlayingMode); + const setMode = useApp((s) => s.setNowPlayingMode); const setFullscreen = useApp((s) => s.setFullscreen); + const navigate = useApp((s) => s.navigate); + const openDialog = useApp((s) => s.openDialog); const covered = useApp((s) => !!s.dialog || s.paletteOpen); - const [tab, setTab] = useState("lyrics"); + const reducedMotion = usePrefersReducedMotion(); const closeRef = useRef(null); + const bigArt = useRef(null); + const thumbArt = useRef(null); + const controls = useRef(null); + /** Where the controls column last rested (the wide layout moves it between modes). */ + const controlsAt = useRef<{ rect: DOMRect; mode: NowPlayingMode } | undefined>(undefined); + /** Where the visible artwork last came to rest, and in which mode. */ + const flight = useRef<{ rect: DOMRect; radius: number; mode: NowPlayingMode } | undefined>(undefined); useReturnFocus(true); useEffect(() => closeRef.current?.focus({ preventScroll: true }), []); const d = bridge().dispatch; const track = now?.track; const playing = transport.position.isPlaying; + const owner = devices.find((x) => x.id === transport.lease.owner); + const remote = owner && !owner.isSelf ? owner.name : undefined; + const source = queue.contextLabel ?? track?.album; useEffect(() => { bridge().window.openFullscreen(true); return () => bridge().window.openFullscreen(false); }, []); - const providerLabel = (p: string) => t(`related.provider.${p}` as never); + const visibleArt = (): HTMLElement | null => (mode === "art" ? bigArt.current : thumbArt.current); + const choose = (next: NowPlayingMode) => setMode(next); + // After every commit: when the mode changed (a click, or L/Q from the + // keyboard), fly the artwork from its last resting place into its new slot; + // on open, fly it up from the player bar's. Then remember where it rests. + useLayoutEffect(() => { + const el = visibleArt(); + const prev = flight.current; + if (!el) { + flight.current = undefined; + return; + } + const radius = radiusOf(el); + if (el.getAnimations().length && prev?.mode === mode) return; + let from: { rect: DOMRect; radius: number } | undefined; + if (!prev) { + const barArt = document.querySelector('[data-testid="player-bar"] .now .art'); + if (barArt) from = { rect: barArt.getBoundingClientRect(), radius: 4 }; + } else if (prev.mode !== mode && (prev.mode === "art" || mode === "art")) { + from = prev; + } + const rect = from && !reducedMotion ? flyFrom(el, from.rect, from.radius, radius) : el.getBoundingClientRect(); + flight.current = { rect, radius, mode }; + }); + // The wide layout re-centres the controls column when the mode changes: glide it there too. + useLayoutEffect(() => { + const el = controls.current; + if (!el) return; + const prev = controlsAt.current; + if (el.getAnimations().length && prev?.mode === mode) return; + const to = el.getBoundingClientRect(); + if (prev && prev.mode !== mode && !reducedMotion) { + const dx = prev.rect.left - to.left; + const dy = prev.rect.top - to.top; + if (Math.abs(dx) > 1 || Math.abs(dy) > 1) el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration: SPRING_SPATIAL.duration, easing: SPRING_SPATIAL.easing }); + } + controlsAt.current = { rect: to, mode }; + }); + const onKey = (e: React.KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) { e.preventDefault(); @@ -49,59 +153,212 @@ export function FullscreenPlayer() { } trapTab(e); }; + const go = (route: Parameters[0]) => { + navigate(route); + setFullscreen(false); + }; + const repeatLabel = queue.repeat === "off" ? t("player.repeatOff") : queue.repeat === "all" ? t("player.repeatAll") : t("player.repeatOne"); + const art = track ? :
; + return ( -
+
- -
- -
-

{track?.title ?? t("player.nothingPlaying")}

-
{track ? [track.artist, track.album].filter(Boolean).join(" · ") : ""}
+