From 2a791470741bdc96a5bd3d09334292c67127e467 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 01:37:47 +0200 Subject: [PATCH 1/6] feat(webapp): checkpoint the catalogue refresh before the fluid pass Prefetch a tab's first answer before switching to it, give the album page play and queue actions with more albums by the same artist, and add the refresh stylesheet the fluid editorial pass will replace for dark themes. Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 427 ++++++- webapp/src/components/play-set-actions.tsx | 5 + webapp/src/components/song-table.tsx | 38 +- webapp/src/main.tsx | 211 +++- webapp/src/pages/admin-page.tsx | 4 +- webapp/src/pages/album-page.tsx | 125 +- webapp/src/pages/playing-page.tsx | 37 +- webapp/src/pages/queue-page.tsx | 21 +- webapp/src/pages/radio-page.tsx | 5 +- webapp/src/pages/random-page.tsx | 8 +- webapp/src/pages/settings-page.tsx | 2 +- webapp/src/pages/songs-page.tsx | 52 +- webapp/src/state/i18n.tsx | 28 + webapp/src/styles/refresh.css | 1191 ++++++++++++++++++++ webapp/src/styles/styles.css | 49 - 15 files changed, 1981 insertions(+), 222 deletions(-) create mode 100644 webapp/src/styles/refresh.css diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 55368bf2..e13edb5c 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -119,7 +119,12 @@ let bookmarks: Array<{ comment: string | null; created_at: number; updated_at: number; - song: { id: string; title: string; artist: string | null }; + song: { + id: string; + title: string; + artist: string | null; + artwork_hash?: string | null; + }; }> = []; /** Every registration the clients route received. */ let clientWrites: string[] = []; @@ -1166,6 +1171,136 @@ test("renders the studio shell and persists appearance preferences", async ({ } }); +test("keeps navigation and player controls within their own space", async ({ + page, +}, testInfo) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + await expect(page.locator(".player")).toBeVisible(); + + if (testInfo.project.name === "mobile") { + await expect(page.locator(".sidebar")).toBeHidden(); + const bounds = await page.evaluate(() => { + const track = document.querySelector(".player-track")?.getBoundingClientRect(); + const progress = document.querySelector(".player-progress")?.getBoundingClientRect(); + const player = document.querySelector(".player")?.getBoundingClientRect(); + const navigation = document.querySelector(".mobile-navigation")?.getBoundingClientRect(); + return { + trackBottom: track?.bottom ?? 0, + progressTop: progress?.top ?? 0, + playerBottom: player?.bottom ?? 0, + navigationTop: navigation?.top ?? 0, + overflow: document.documentElement.scrollWidth - window.innerWidth, + }; + }); + expect(bounds.progressTop).toBeGreaterThanOrEqual(bounds.trackBottom); + expect(bounds.navigationTop).toBeGreaterThanOrEqual(bounds.playerBottom); + expect(bounds.overflow).toBeLessThanOrEqual(1); + await page.setViewportSize({ width: 320, height: 700 }); + const narrow = await page.evaluate(() => ({ + trackBottom: document.querySelector(".player-track")?.getBoundingClientRect().bottom ?? 0, + progressTop: document.querySelector(".player-progress")?.getBoundingClientRect().top ?? 0, + playerBottom: document.querySelector(".player")?.getBoundingClientRect().bottom ?? 0, + navigationTop: document.querySelector(".mobile-navigation")?.getBoundingClientRect().top ?? 0, + overflow: document.documentElement.scrollWidth - window.innerWidth, + })); + expect(narrow.progressTop).toBeGreaterThanOrEqual(narrow.trackBottom); + expect(narrow.navigationTop).toBeGreaterThanOrEqual(narrow.playerBottom); + expect(narrow.overflow).toBeLessThanOrEqual(1); + } else { + const bounds = await page.evaluate(() => { + const sidebar = document.querySelector(".sidebar")?.getBoundingClientRect(); + const footer = document.querySelector(".sidebar-footer")?.getBoundingClientRect(); + const navigation = document.querySelector(".primary-navigation")?.getBoundingClientRect(); + return { + sidebarBottom: sidebar?.bottom ?? 0, + footerBottom: footer?.bottom ?? 0, + navigationBottom: navigation?.bottom ?? 0, + }; + }); + expect(bounds.footerBottom).toBeLessThanOrEqual(bounds.sidebarBottom); + expect(bounds.navigationBottom).toBeLessThanOrEqual(bounds.footerBottom); + await expect(page.locator(".sidebar-footer").getByRole("button", { name: "Sign out" })).toBeVisible(); + await page.setViewportSize({ width: 821, height: 650 }); + const compact = await page.evaluate(() => ({ + sidebarBottom: document.querySelector(".sidebar")?.getBoundingClientRect().bottom ?? 0, + footerBottom: document.querySelector(".sidebar-footer")?.getBoundingClientRect().bottom ?? 0, + overflow: document.documentElement.scrollWidth - window.innerWidth, + })); + expect(compact.footerBottom).toBeLessThanOrEqual(compact.sidebarBottom); + expect(compact.overflow).toBeLessThanOrEqual(1); + } +}); + +test("shows one current navigation item and separates administration panels", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name !== "desktop", "sidebar layout is desktop-only"); + await page.goto("/settings/scrobbling"); + const current = page.locator(".primary-navigation a[data-status='active']"); + await expect(current).toHaveCount(1); + await expect(current).toHaveText("Scrobbling"); + + const settings = page.locator(".primary-navigation").getByRole("link", { + name: "Settings", + }); + await settings.hover(); + const activeBackground = await current.evaluate((node) => + getComputedStyle(node).backgroundColor, + ); + const hoverBackground = await settings.evaluate((node) => + getComputedStyle(node).backgroundColor, + ); + expect(hoverBackground).not.toBe(activeBackground); + + await page.goto("/admin"); + await expect(page.locator(".admin-page > .stack .user-row")).toBeVisible(); + await expect(page.locator(".now-playing-panel")).toBeVisible(); + const gap = await page.evaluate(() => { + const accounts = document.querySelector(".admin-page > .stack"); + const playing = document.querySelector(".now-playing-panel"); + return ( + (playing?.getBoundingClientRect().top ?? 0) - + (accounts?.getBoundingClientRect().bottom ?? 0) + ); + }); + expect(gap).toBeGreaterThanOrEqual(24); +}); + +test("puts the songs playback action beside the sort and names its scope", async ({ + page, +}) => { + await page.route("**/api/v2/songs?*", (route) => + route.fulfill({ + json: [track, { ...track, id: "song-2", title: "Second track" }], + }), + ); + await page.goto("/songs"); + const play = page.locator(".page-header").getByRole("button", { + name: "Play all 2 tracks", + }); + await expect(play).toBeVisible(); + await play.click(); + await expect(page.locator(".player-track strong")).toHaveText("Army of Me"); +}); + +test("gives random playback one clear primary action", async ({ page }) => { + await page.route("**/api/v2/songs/random?*", (route) => + route.fulfill({ json: [track] }), + ); + await page.goto("/random"); + const controls = page.locator(".page-header"); + const play = controls.getByRole("button", { name: "Play", exact: true }); + const queue = controls.getByRole("button", { name: "Add queue" }); + const redraw = controls.getByRole("button", { name: "Draw again" }); + await expect(play).toBeVisible(); + await expect(queue).toBeVisible(); + await expect(redraw).toBeVisible(); + const playFill = await play.evaluate((node) => getComputedStyle(node).backgroundColor); + const queueFill = await queue.evaluate((node) => getComputedStyle(node).backgroundColor); + expect(playFill).not.toBe(queueFill); +}); + test("offers a keyboard skip link and a localized not-found route", async ({ page, }) => { @@ -1193,6 +1328,19 @@ test("has no automated WCAG A or AA violations", async ({ page }) => { expect(results.violations).toEqual([]); }); +test("keeps the light appearance readable", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("waveflow.theme", "default"); + }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "default"); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); +}); + /** * The browse controls are the whole of lot A that has behaviour rather than * appearance. Sorting has to reach the server — it is `AlbumOrder` there, and @@ -1338,6 +1486,43 @@ test("keeps each card's actions guarded while its own fetch is out", async ({ await expect(slowPlay).toBeEnabled(); }); +test("album view plays its tracks and keeps its compact list readable", async ({ + page, +}) => { + await page.goto("/albums/album-2"); + await expect(page.locator(".album-hero h2")).toHaveText("Vespertine"); + await expect(page.locator(".album-meta")).toContainText("3 tracks"); + await expect(page.locator(".album-meta")).toContainText("15:00"); + await expect(page.locator(".album-songs tbody tr")).toHaveCount(3); + await expect(page.locator(".album-songs .song-cover")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "More by Björk" })).toBeVisible(); + await expect(page.locator(".album-related-grid a")).toHaveCount(1); + const actions = await page.locator(".album-actions button").evaluateAll((buttons) => + buttons.map((button) => { + const style = getComputedStyle(button); + return { + left: Number.parseFloat(style.paddingLeft), + right: Number.parseFloat(style.paddingRight), + }; + }), + ); + expect(actions).toHaveLength(2); + for (const action of actions) { + expect(action.left).toBeGreaterThanOrEqual(20); + expect(action.right).toBeGreaterThanOrEqual(20); + } + + await page.getByRole("button", { name: "Play album" }).click(); + await expect(page.locator(".player-track strong")).toHaveText("Hidden Place"); + await page.locator(".album-songs .song-title").nth(1).click(); + await expect(page.locator(".player-track strong")).toHaveText("Cocoon"); + expect( + await page.evaluate(() => + document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); +}); + /** * The song table carries the controls the albums grid does not — a five-star * rating and a favourite — so the accessibility sweep has to reach a page that @@ -1774,6 +1959,81 @@ test("carries shuffle and the three repeat states in the player bar", async ({ ).toHaveAttribute("href", "/albums/album-2"); }); +test("shows queue artwork beside tracks without crowding times or actions", async ({ + page, +}) => { + const songs = [ + { ...track, artwork_hash: "queue-cover-1" }, + { + ...track, + id: "song-2", + title: "A much longer title in the listening queue", + duration_ms: 92_000, + artwork_hash: "queue-cover-2", + }, + { + ...track, + id: "song-3", + title: "Rising", + duration_ms: 236_000, + artwork_hash: "queue-cover-3", + }, + ]; + await page.route("**/api/v2/queue", async (route) => { + await route.fulfill({ + json: { + current: "song-1", + position_ms: 0, + changed_by: null, + updated_at: 1, + songs, + }, + }); + }); + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ); + await page.route("**/api/v2/artwork/*", async (route) => { + await route.fulfill({ contentType: "image/png", body: png }); + }); + + await page.goto("/queue"); + const rows = page.locator(".queue-list li"); + await expect(rows).toHaveCount(3); + await rows.last().scrollIntoViewIfNeeded(); + const covers = page.locator(".queue-list img.queue-cover"); + await expect(covers).toHaveCount(3); + await expect + .poll(() => + covers.evaluateAll((images) => + images.every((image) => (image as HTMLImageElement).naturalWidth > 0), + ), + ) + .toBe(true); + const timeEdges = await page.locator(".queue-duration").evaluateAll((times) => + times.map((time) => Math.round(time.getBoundingClientRect().right)), + ); + expect(new Set(timeEdges).size).toBe(1); + + await page.setViewportSize({ width: 320, height: 700 }); + const narrow = await rows.first().evaluate((row) => { + const [song, time, remove] = [...row.children].map((child) => + child.getBoundingClientRect(), + ); + return { + songRight: song?.right ?? 0, + timeLeft: time?.left ?? 0, + timeRight: time?.right ?? 0, + removeLeft: remove?.left ?? 0, + overflow: document.documentElement.scrollWidth - window.innerWidth, + }; + }); + expect(narrow.songRight).toBeLessThanOrEqual(narrow.timeLeft); + expect(narrow.timeRight).toBeLessThanOrEqual(narrow.removeLeft); + expect(narrow.overflow).toBeLessThanOrEqual(1); +}); + /** * The web client works inside one library at a time — decided 2026-09-07, and * the contract is that changing library changes the catalogue's scope rather @@ -2340,6 +2600,71 @@ test("offers removal but no new canvas where the library takes none", async ({ * name. It steps aside when motion is reduced, and when the browser cannot play * it — the cover shows then, never an empty frame. */ +/** Saved positions use their song artwork and a shared time column. */ +test("shows saved tracks with artwork and one aligned time column", async ({ + page, +}) => { + bookmarks = ["Soleil Bleu", "A much longer song title", "Rising"].map( + (title, index) => ({ + position_ms: [224_000, 236_000, 92_000][index] as number, + comment: null, + created_at: 1, + updated_at: 1, + song: { + id: `saved-${index}`, + title, + artist: "Rue Delacour", + artwork_hash: `saved-cover-${index}`, + }, + }), + ); + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ); + await page.route("**/api/v2/artwork/*", async (route) => { + await route.fulfill({ contentType: "image/png", body: png }); + }); + + await page.goto("/playing"); + const rows = page.locator(".bookmark-list li"); + await expect(rows).toHaveCount(3); + await rows.last().scrollIntoViewIfNeeded(); + const covers = page.locator(".bookmark-list img.bookmark-cover"); + await expect(covers).toHaveCount(3); + await expect + .poll(() => + covers.evaluateAll((images) => + images.every((image) => (image as HTMLImageElement).naturalWidth > 0), + ), + ) + .toBe(true); + const timeEdges = await page.locator(".bookmark-position").evaluateAll( + (times) => times.map((time) => Math.round(time.getBoundingClientRect().right)), + ); + expect(new Set(timeEdges).size).toBe(1); + await expect(page.locator(".playing-bookmarks")).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + await page.setViewportSize({ width: 320, height: 700 }); + const narrow = await rows.first().evaluate((row) => { + const [track, time, forget] = [...row.children].map((child) => + child.getBoundingClientRect(), + ); + return { + trackRight: track?.right ?? 0, + timeLeft: time?.left ?? 0, + timeRight: time?.right ?? 0, + forgetLeft: forget?.left ?? 0, + overflow: document.documentElement.scrollWidth - window.innerWidth, + }; + }); + expect(narrow.trackRight).toBeLessThanOrEqual(narrow.timeLeft); + expect(narrow.timeRight).toBeLessThanOrEqual(narrow.forgetLeft); + expect(narrow.overflow).toBeLessThanOrEqual(1); +}); + /** * The playing page, reading what the server actually sends. * @@ -2804,73 +3129,53 @@ test("offers nothing in the sheet that the account may not reach", async ({ await expect(sheet.getByRole("link", { name: "Upload" })).toHaveCount(0); }); -/** - * A page already visited comes back without passing through its skeleton. - * - * Every screen used to load on mount and blank itself first, so leaving a page - * and returning drew the loading state again even when the answer took nine - * milliseconds. Measured on a real library over a local network, that was 79ms - * of flicker on every navigation — brief, and on every single one. - * - * **The delay below is what makes this test able to fail.** Every other mock in - * this file answers instantly, and against an instant answer a cache miss and a - * cache hit look identical: both paint within a frame and neither shows a - * skeleton long enough to observe. A question that takes 150ms leaves a mark if - * it is asked, and none if it is not. - * - * The first visit still draws it, and that is deliberate — a page holding - * nothing has to say it is working. Losing that would trade a flicker for a - * screen that looks broken on a slow link. - */ -test("draws the skeleton on a first visit, and never again on a return", async ({ +/** A tab switch keeps the current screen until the destination can render. */ +test("keeps the current tab visible while the next tab loads", async ({ page, }) => { const asked: string[] = []; - for (const path of ["/api/v2/genres*", "/api/v2/albums*"]) { - await page.route(`**${path}`, async (route) => { - const url = new URL(route.request().url()); - asked.push(url.pathname); - await new Promise((resolve) => setTimeout(resolve, 150)); - await route.fulfill({ - json: url.pathname.endsWith("genres") - ? [{ name: "Trip hop", song_count: 4, album_count: 1 }] - : albums, - }); + let answerGenres: (() => void) | undefined; + const genresReady = new Promise((resolve) => { + answerGenres = resolve; + }); + await page.route("**/api/v2/genres*", async (route) => { + asked.push(new URL(route.request().url()).pathname); + await genresReady; + await route.fulfill({ + json: [{ name: "Trip hop", song_count: 4, album_count: 1 }], }); - } + }); + await page.route("**/api/v2/albums*", async (route) => { + asked.push(new URL(route.request().url()).pathname); + await route.fulfill({ json: albums }); + }); await page.goto("/"); await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); - - const seen = await page.evaluate(async () => { - // Watched across the whole navigation rather than sampled after it: the - // skeleton is on screen for a matter of frames when it is on screen at all, - // and a check that ran once would miss it and call the flicker fixed. - const visit = async (label: string) => { - const started = performance.now(); - let appeared = false; - const link = [...document.querySelectorAll("a")].find((anchor) => - anchor.textContent?.includes(label), - ) as HTMLElement; - link.click(); - return await new Promise((resolve) => { - const tick = () => { - if (document.querySelector(".skeleton-grid")) appeared = true; - if (performance.now() - started > 500) return resolve(appeared); - requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }); - }; - return { - first: await visit("Genres"), - back: await visit("Albums"), - again: await visit("Genres"), - }; - }); - - expect(seen).toEqual({ first: true, back: false, again: false }); - // And the reason: the return asked nothing. Three visits, two questions. + const mobile = await page.locator(".mobile-navigation").isVisible(); + const genresLink = mobile + ? page.locator('.more-sheet a[href="/genres"]') + : page.locator(".primary-navigation").getByRole("link", { name: "Genres" }); + async function visitGenres() { + if (mobile) { + await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click(); + } + await genresLink.click(); + } + await visitGenres(); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + await expect(page.locator(".skeleton-grid")).toHaveCount(0); + await expect(genresLink).toHaveAttribute( + "aria-busy", + "true", + ); + answerGenres?.(); + await expect(page.getByRole("heading", { name: "Genres" })).toBeVisible(); + await page.getByRole("link", { name: "Albums" }).click(); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + await visitGenres(); + await expect(page.getByRole("heading", { name: "Genres" })).toBeVisible(); + await expect(page.locator(".skeleton-grid")).toHaveCount(0); expect(asked).toEqual(["/api/v2/albums", "/api/v2/genres"]); }); diff --git a/webapp/src/components/play-set-actions.tsx b/webapp/src/components/play-set-actions.tsx index 53955134..edfbc478 100644 --- a/webapp/src/components/play-set-actions.tsx +++ b/webapp/src/components/play-set-actions.tsx @@ -1,6 +1,7 @@ import type { Song } from "../api"; import { usePlayer } from "../player/player"; import { useI18n } from "../state/i18n"; +import { Icon } from "./icons"; /** Plays a set of songs, or adds them to the queue, from a page header. */ export function PlaySetActions({ songs }: { songs: Song[] }) { @@ -9,17 +10,21 @@ export function PlaySetActions({ songs }: { songs: Song[] }) { return (
diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index 44fcc4f0..06fcfc77 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -60,9 +60,11 @@ function StarRating({ export function SongTable({ songs, numbering = "position", + variant = "default", }: { songs: Song[]; numbering?: "track" | "position"; + variant?: "default" | "album"; }) { const player = usePlayer(); const { t } = useI18n(); @@ -134,7 +136,21 @@ export function SongTable({ return (
- +
+ {variant === "album" ? ( + + + + + + + + + ) : null} {songs.map((song, position) => { const starred = stars[song.id] ?? song.starred_at !== null; @@ -155,19 +171,23 @@ export function SongTable({ className="link song-title" onClick={() => player.play(songs, position)} > - + {variant === "default" ? ( + + ) : null} {song.title} - {song.album} + {variant === "album" ? song.artist : song.album} - - + {variant === "default" ? ( + + ) : null} + - + ) : null} - +
#{t("album.title")} + {t("album.duration")} + + {t("album.trackActions")} +
{song.artist}{formatDuration(song.duration_ms)}{song.artist}{formatDuration(song.duration_ms)}
{ + switch (to) { + case "/": + await client.ensureQueryData(albumsQuery("alphabeticalByName", scope)); + break; + case "/artists": + await client.ensureQueryData(artistsQuery(scope)); + break; + case "/songs": + await client.ensureInfiniteQueryData( + songsQuery("alphabeticalByName", scope), + ); + break; + case "/genres": + await client.ensureQueryData(genresQuery(scope)); + break; + case "/random": + await client.ensureQueryData(randomSongsQuery(scope)); + break; + case "/history": + await client.ensureQueryData(historyTracksQuery(50)); + break; + case "/favourites": + await client.ensureQueryData(favoriteTracksQuery()); + break; + case "/playlists": + await client.ensureQueryData(playlistsQuery()); + break; + case "/shares": + await client.ensureQueryData(sharesQuery()); + break; + case "/radio": + await client.ensureQueryData(radioStationsQuery()); + break; + case "/settings": + await Promise.all([ + client.ensureQueryData(ownApiTokensQuery()), + client.ensureQueryData(ownSubsonicCredentialQuery()), + ]); + break; + case "/settings/scrobbling": + await Promise.all([ + client.ensureQueryData(scrobbleRowsQuery()), + client.ensureQueryData(uncertainScrobblesQuery()), + ]); + break; + case "/admin": + await client.ensureQueryData(administrationQuery()); + break; + // These screens draw their frame immediately or read state already held + // by the player. They have no page-wide answer to wait for. + case "/search": + case "/playing": + case "/queue": + case "/upload": + break; + } +} + function Brand() { const { t } = useI18n(); return ( @@ -148,6 +243,10 @@ function Navigation({ mobile = false }: { mobile?: boolean }) { const user = currentUser(); const { active } = useLibraryScope(); const { t } = useI18n(); + const client = useQueryClient(); + const navigate = useNavigate(); + const [pendingTab, setPendingTab] = useState(null); + const navigationAttempt = useRef(0); const visible = navigation.filter((item) => permitted(item, user?.role, active), ); @@ -158,6 +257,42 @@ function Navigation({ mobile = false }: { mobile?: boolean }) { // it. Promoting one would have made the others more invisible by contrast. const overflow = mobile ? visible.filter((item) => !item.primary) : []; const sheet = useRef(null); + + function openTab(event: MouseEvent, to: NavigationTarget) { + // Leave modified and middle clicks to the link, so opening a new tab keeps + // the browser's normal behaviour. Only an ordinary in-app tab switch waits. + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + event.defaultPrevented + ) { + return; + } + event.preventDefault(); + if (window.location.pathname === to) { + // A second click on the current tab cancels a destination still loading. + navigationAttempt.current += 1; + setPendingTab(null); + return; + } + const attempt = ++navigationAttempt.current; + setPendingTab(to); + void (async () => { + try { + await prepareTab(to, active?.id, client); + } catch { + // The destination owns its error state. Navigate to it even when its + // first request fails, rather than leaving the old tab stuck as busy. + } + if (attempt !== navigationAttempt.current) return; + setPendingTab(null); + await navigate({ to }); + })(); + } + return ( <>
# + # + {t("album.title")} {t("album.duration")} @@ -180,14 +182,18 @@ export function SongTable({ ) : null} {song.title} - {variant === "album" ? song.artist : song.album} + + {variant === "album" ? song.artist : song.album} + {variant === "default" ? ( {song.artist}{formatDuration(song.duration_ms)} + {formatDuration(song.duration_ms)} +
- + {song.album_id ? ( {t("correction.back", { album: song.album ?? song.title })} diff --git a/webapp/src/lib/cover-light.test.ts b/webapp/src/lib/cover-light.test.ts new file mode 100644 index 00000000..ff9c0249 --- /dev/null +++ b/webapp/src/lib/cover-light.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { averageColour, glowOf, shade } from "./cover-light"; + +/** `count` sampled pixels of one colour, laid out the way the walk reads them. */ +function pixels(colour: [number, number, number, number], count: number) { + const data: number[] = []; + for (let n = 0; n < count; n += 1) { + // The walk reads one pixel in four, so three unread ones follow each. + data.push(...colour, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + return data; +} + +describe("the cover's average colour", () => { + it("averages the colourful pixels", () => { + const data = [ + ...pixels([200, 40, 40, 255], 3), + ...pixels([40, 40, 200, 255], 1), + ]; + expect(averageColour(data)).toEqual({ r: 160, g: 40, b: 80 }); + }); + + it("leaves out the margins, the bars and the transparent pixels", () => { + // Without the skip, the white and the black would drag this to grey. + const data = [ + ...pixels([200, 40, 40, 255], 1), + ...pixels([250, 250, 250, 255], 5), + ...pixels([5, 5, 5, 255], 5), + ...pixels([40, 200, 40, 10], 5), + ]; + expect(averageColour(data)).toEqual({ r: 200, g: 40, b: 40 }); + }); + + it("has nothing to say about a cover with nothing left", () => { + expect(averageColour(pixels([255, 255, 255, 255], 4))).toBeNull(); + expect(averageColour([])).toBeNull(); + }); +}); + +describe("the light it becomes", () => { + it("deepens a mid-tone cover by the factor asked", () => { + expect(shade({ r: 100, g: 100, b: 200 }, 0.5)).toBe("#323264"); + }); + + it("pulls a bright cover down harder than a mid-tone one", () => { + // Luminance above 0.6 takes 55% of the factor: a pale sleeve must never + // pale the ground. + expect(shade({ r: 240, g: 240, b: 240 }, 0.5)).toBe("#424242"); + }); + + it("lifts a very dark cover so it still shows", () => { + expect(shade({ r: 20, g: 10, b: 10 }, 0.5)).toBe("#120909"); + }); + + it("makes the deep pool darker than the near one", () => { + const { glow, deep } = glowOf({ r: 52, g: 211, b: 153 }); + expect(Number.parseInt(deep.slice(1, 3), 16)).toBeLessThan( + Number.parseInt(glow.slice(1, 3), 16), + ); + }); +}); diff --git a/webapp/src/lib/cover-light.ts b/webapp/src/lib/cover-light.ts new file mode 100644 index 00000000..d9cb37df --- /dev/null +++ b/webapp/src/lib/cover-light.ts @@ -0,0 +1,92 @@ +/** + * The cover's light: the colour the Fluid Editorial direction spreads behind + * the page, read off the playing record's sleeve. + * + * Ported from the desktop's `dominantColor` and `useCoverGlow`, so the two + * clients light a page the same way from the same cover. No React here: the + * pixel arithmetic is what can be wrong, and it is tested on its own. + */ + +export type Rgb = { r: number; g: number; b: number }; + +/** + * Average the pixels that carry the cover's feel. + * + * Every fourth pixel of the sample, skipping the transparent ones and the + * near-monochrome extremes — a white margin or a black bar would otherwise + * flatten every average towards grey. `null` when nothing is left, so the + * caller keeps the light it already has rather than inventing one. + */ +export function averageColour(data: ArrayLike): Rgb | null { + let r = 0; + let g = 0; + let b = 0; + let count = 0; + for (let i = 0; i + 3 < data.length; i += 16) { + const pr = data[i]; + const pg = data[i + 1]; + const pb = data[i + 2]; + if (data[i + 3] < 128) continue; + const max = Math.max(pr, pg, pb); + const min = Math.min(pr, pg, pb); + if (max - min < 24 && (max < 32 || max > 224)) continue; + r += pr; + g += pg; + b += pb; + count += 1; + } + if (count === 0) return null; + return { + r: Math.round(r / count), + g: Math.round(g / count), + b: Math.round(b / count), + }; +} + +/** + * Scale a colour towards black, harder for a bright cover and softer for a + * dark one, so a white sleeve never pales the ground and a black one still + * shows. The factors are the desktop's. + */ +export function shade({ r, g, b }: Rgb, k: number): string { + const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + const factor = lum > 0.6 ? k * 0.55 : lum < 0.12 ? k * 1.8 : k; + const channel = (v: number) => + Math.max(0, Math.min(255, Math.round(v * factor))) + .toString(16) + .padStart(2, "0"); + return `#${channel(r)}${channel(g)}${channel(b)}`; +} + +/** The two pools: the cover's tone deepened, and the same again, darker. */ +export function glowOf(colour: Rgb): { glow: string; deep: string } { + return { glow: shade(colour, 0.62), deep: shade(colour, 0.3) }; +} + +const SAMPLE = 64; + +/** + * Read a cover's average colour from an image URL. + * + * The URL is an object URL this client made from bytes it fetched itself, so + * the canvas is never tainted and no CORS question arises. + */ +export function sampleCover(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = SAMPLE; + canvas.height = SAMPLE; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) { + resolve(null); + return; + } + context.drawImage(image, 0, 0, SAMPLE, SAMPLE); + resolve(averageColour(context.getImageData(0, 0, SAMPLE, SAMPLE).data)); + }; + image.onerror = () => reject(new Error("cover could not be decoded")); + image.src = url; + }); +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 887f2eeb..c74b7441 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -47,6 +47,7 @@ import { uncertainScrobblesQuery, } from "./api/queries"; import { createQueryClient } from "./api/query-client"; +import { CoverLight } from "./components/cover-light"; import { Icon, type IconName } from "./components/icons"; import { Waiting } from "./components/page-shell"; import { TrackEditorPage } from "./components/track-editor"; @@ -85,6 +86,30 @@ import { import { PreferencesProvider } from "./state/preferences"; import "./styles/styles.css"; import "./styles/refresh.css"; +// The Fluid Editorial faces. Declared for every theme and fetched only where a +// rule names them, which is the dark themes alone. +import "@fontsource-variable/dm-sans/opsz.css"; +import "@fontsource/playfair-display/400-italic.css"; +import "./styles/fluid.css"; + +/** + * The rail's sections, in the order it shows them. + * + * `pinned` is the four places reached for most, drawn as tiles by the dark + * themes; the others carry a label. A light theme keeps its plain list, so the + * grouping changes its order and nothing else. + */ +const NAVIGATION_GROUPS = ["pinned", "library", "listen", "account"] as const; +type NavigationGroup = (typeof NAVIGATION_GROUPS)[number]; + +const GROUP_LABELS: Record< + Exclude, + TranslationKey +> = { + library: "nav.group.library", + listen: "nav.group.listen", + account: "nav.group.account", +}; const navigation: Array<{ to: @@ -111,38 +136,89 @@ const navigation: Array<{ /** Shown only where the active library takes files from this account. */ upload?: boolean; primary?: boolean; + /** The rail section it is filed under. */ + group: NavigationGroup; }> = [ - { to: "/", labelKey: "nav.albums", icon: "albums", primary: true }, - { to: "/artists", labelKey: "nav.artists", icon: "artists" }, - { to: "/songs", labelKey: "nav.songs", icon: "songs" }, - { to: "/genres", labelKey: "nav.genres", icon: "genres" }, - { to: "/search", labelKey: "nav.search", icon: "search", primary: true }, - { to: "/playing", labelKey: "nav.playing", icon: "lyrics", primary: true }, - { to: "/random", labelKey: "nav.random", icon: "random" }, - { to: "/history", labelKey: "nav.history", icon: "history" }, + { + to: "/", + labelKey: "nav.albums", + icon: "albums", + primary: true, + group: "pinned", + }, + { to: "/artists", labelKey: "nav.artists", icon: "artists", group: "pinned" }, + { to: "/songs", labelKey: "nav.songs", icon: "songs", group: "pinned" }, + { to: "/genres", labelKey: "nav.genres", icon: "genres", group: "library" }, + { + to: "/search", + labelKey: "nav.search", + icon: "search", + primary: true, + group: "pinned", + }, + { + to: "/playing", + labelKey: "nav.playing", + icon: "lyrics", + primary: true, + group: "listen", + }, + { to: "/random", labelKey: "nav.random", icon: "random", group: "listen" }, + { + to: "/history", + labelKey: "nav.history", + icon: "history", + group: "library", + }, { to: "/favourites", labelKey: "nav.favourites", icon: "heart", primary: true, + group: "library", }, { to: "/playlists", labelKey: "nav.playlists", icon: "playlists", primary: true, + group: "library", + }, + { + to: "/queue", + labelKey: "nav.queue", + icon: "queue", + primary: true, + group: "listen", + }, + { to: "/shares", labelKey: "nav.shares", icon: "shares", group: "account" }, + { to: "/radio", labelKey: "nav.radio", icon: "radio", group: "listen" }, + { + to: "/settings", + labelKey: "nav.settings", + icon: "admin", + group: "account", }, - { to: "/queue", labelKey: "nav.queue", icon: "queue", primary: true }, - { to: "/shares", labelKey: "nav.shares", icon: "shares" }, - { to: "/radio", labelKey: "nav.radio", icon: "radio" }, - { to: "/settings", labelKey: "nav.settings", icon: "admin" }, { to: "/settings/scrobbling", labelKey: "nav.scrobbling", icon: "scrobbling", + group: "account", + }, + { + to: "/upload", + labelKey: "nav.upload", + icon: "upload", + upload: true, + group: "account", + }, + { + to: "/admin", + labelKey: "nav.admin", + icon: "admin", + admin: true, + group: "account", }, - { to: "/upload", labelKey: "nav.upload", icon: "upload", upload: true }, - { to: "/admin", labelKey: "nav.admin", icon: "admin", admin: true }, ]; type NavigationTarget = (typeof navigation)[number]["to"]; @@ -293,26 +369,39 @@ function Navigation({ mobile = false }: { mobile?: boolean }) { })(); } + const entry = (item: (typeof navigation)[number]) => ( + openTab(event, item.to)} + aria-busy={pendingTab === item.to} + aria-label={mobile ? t(item.labelKey) : undefined} + activeOptions={{ + exact: item.to === "/" || item.to === "/settings", + }} + > + + {t(item.labelKey)} + + ); + return ( <>
{related?.length ? ( -
+
{related.map((album) => ( @@ -107,10 +112,7 @@ export function AlbumPage({ albumId }: { albumId: string }) { to="/albums/$albumId" params={{ albumId: album.id }} > - + {album.title} {album.year ? {album.year} : null} diff --git a/webapp/src/pages/albums-page.tsx b/webapp/src/pages/albums-page.tsx index 37756f17..1567321d 100644 --- a/webapp/src/pages/albums-page.tsx +++ b/webapp/src/pages/albums-page.tsx @@ -79,7 +79,9 @@ export function AlbumsPage() { {/* No detail line: the count it would carry is exactly what is still missing, and `Loading` already says whether this is a wait or a failure. */} - {controls} + + {controls} +
); @@ -87,6 +89,7 @@ export function AlbumsPage() { return (
diff --git a/webapp/src/pages/genre-page.tsx b/webapp/src/pages/genre-page.tsx index 952eee84..d31066f8 100644 --- a/webapp/src/pages/genre-page.tsx +++ b/webapp/src/pages/genre-page.tsx @@ -38,6 +38,7 @@ export function GenrePage({ genre }: { genre: string }) { return (
diff --git a/webapp/src/pages/genres-page.tsx b/webapp/src/pages/genres-page.tsx index a75735ff..aadf3ac6 100644 --- a/webapp/src/pages/genres-page.tsx +++ b/webapp/src/pages/genres-page.tsx @@ -26,6 +26,7 @@ export function GenresPage() { return (
diff --git a/webapp/src/pages/playing-page.tsx b/webapp/src/pages/playing-page.tsx index 8a660d6b..d4a27c9a 100644 --- a/webapp/src/pages/playing-page.tsx +++ b/webapp/src/pages/playing-page.tsx @@ -497,6 +497,7 @@ export function PlayingPage() { return (
diff --git a/webapp/src/pages/playlists-page.tsx b/webapp/src/pages/playlists-page.tsx index c05886fd..28f34743 100644 --- a/webapp/src/pages/playlists-page.tsx +++ b/webapp/src/pages/playlists-page.tsx @@ -79,6 +79,7 @@ export function PlaylistsPage() { return (
diff --git a/webapp/src/pages/queue-page.tsx b/webapp/src/pages/queue-page.tsx index f751544b..37b656b5 100644 --- a/webapp/src/pages/queue-page.tsx +++ b/webapp/src/pages/queue-page.tsx @@ -20,6 +20,7 @@ export function QueuePage() { return (
diff --git a/webapp/src/pages/radio-page.tsx b/webapp/src/pages/radio-page.tsx index 6e92d21e..db578084 100644 --- a/webapp/src/pages/radio-page.tsx +++ b/webapp/src/pages/radio-page.tsx @@ -249,7 +249,11 @@ export function RadioPage() { return (
- + void submit(event)} diff --git a/webapp/src/pages/random-page.tsx b/webapp/src/pages/random-page.tsx index 05d2e8cb..c2ad2ab0 100644 --- a/webapp/src/pages/random-page.tsx +++ b/webapp/src/pages/random-page.tsx @@ -15,6 +15,7 @@ export function RandomPage() { return (
diff --git a/webapp/src/pages/scrobbling-page.tsx b/webapp/src/pages/scrobbling-page.tsx index 42441cbb..ee897376 100644 --- a/webapp/src/pages/scrobbling-page.tsx +++ b/webapp/src/pages/scrobbling-page.tsx @@ -160,6 +160,7 @@ export function ScrobblingPage() { return (
diff --git a/webapp/src/pages/search-page.tsx b/webapp/src/pages/search-page.tsx index 07b631e1..d00793a6 100644 --- a/webapp/src/pages/search-page.tsx +++ b/webapp/src/pages/search-page.tsx @@ -48,7 +48,11 @@ export function SearchPage() { return (
- + {/* Said only where it is a fact worth saying. This screen used to warn that it covered every library, because it did; it is scoped like the others now, and an account with one library is not choosing. */} diff --git a/webapp/src/pages/settings-page.tsx b/webapp/src/pages/settings-page.tsx index 109a7277..81492094 100644 --- a/webapp/src/pages/settings-page.tsx +++ b/webapp/src/pages/settings-page.tsx @@ -354,7 +354,11 @@ export function SettingsPage() { const { t } = useI18n(); return (
- +

{t("settings.appearance")}

diff --git a/webapp/src/pages/shares-page.tsx b/webapp/src/pages/shares-page.tsx index 522c064f..4378f49b 100644 --- a/webapp/src/pages/shares-page.tsx +++ b/webapp/src/pages/shares-page.tsx @@ -66,7 +66,11 @@ export function SharesPage() { return (
- + void shareQueue(event)} diff --git a/webapp/src/pages/songs-page.tsx b/webapp/src/pages/songs-page.tsx index defd702b..1d5f04b5 100644 --- a/webapp/src/pages/songs-page.tsx +++ b/webapp/src/pages/songs-page.tsx @@ -92,7 +92,9 @@ export function SongsPage() { if (!data) { return (
- {controls} + + {controls} +
); @@ -104,6 +106,7 @@ export function SongsPage() { the route sends no total, and inventing one would mean a second `COUNT(*)` over the same filter on every request. */} - +

{t("upload.noLibrary")}

); @@ -349,7 +349,10 @@ export function UploadPage() { const mayWrite = active.role === "owner" || active.role === "manager"; return (
- +

{t(mayWrite ? "upload.closed" : "upload.notAllowed")}

@@ -359,7 +362,10 @@ export function UploadPage() { return (
- +

{t("upload.detail")}

-
+ {/* `--played` is the share already heard. The dark themes draw that part + of the line as the mark's wave and leave the rest straight, beneath a + range input that keeps every interaction. */} +
0 + ? Math.min( + 1, + (scrubbing ?? progress.position) / progress.duration, + ) + : 0, + } as CSSProperties + } + > {formatDuration((scrubbing ?? progress.position) * 1000)} +