diff --git a/src/api/catalog.rs b/src/api/catalog.rs index cef99db1..5ed70fe2 100644 --- a/src/api/catalog.rs +++ b/src/api/catalog.rs @@ -175,6 +175,29 @@ pub async fn get_artist( .map_err(service_error) } +/// The albums credited to an artist, as album artist, that this account can see. +/// +/// The albums [`get_artist`] carries, without the rest of its page. That page +/// is the artist's own screen: it asks the enrichment engine, which schedules +/// a fill from every configured provider whose row is missing or stale. An +/// album page that only wants the artist's other records would otherwise send +/// a request to a third party for every album opened — or page through the +/// whole library to keep six of them. +#[utoipa::path(get, path = "/api/v2/artists/{artist_id}/albums", tag = "catalog", params(("artist_id" = Uuid, Path)), responses((status = 200, body = [crate::services::AlbumItem]), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +pub async fn list_artist_albums( + State(state): State, + Path(artist_id): Path, + headers: HeaderMap, +) -> Result>, ApiError> { + let user = authenticated(&state, &headers, Access::Read).await?; + state + .services + .artist(user.id, artist_id) + .await + .map(|detail| Json(detail.albums)) + .map_err(service_error) +} + /// An artist's most played tracks, for this account. /// /// Its own route rather than a field on [`get_artist`]: the artist page draws diff --git a/src/api/mod.rs b/src/api/mod.rs index fcea65e3..dbae6090 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -151,6 +151,10 @@ pub fn router(state: AppState) -> Router { "/api/v2/artists/{artist_id}/top-songs", get(list_artist_top_songs), ) + .route( + "/api/v2/artists/{artist_id}/albums", + get(list_artist_albums), + ) .route("/api/v2/search", get(search_catalog)) .route("/api/v2/songs", get(list_songs)) .route("/api/v2/songs/by-genre", get(list_songs_by_genre)) diff --git a/src/lib.rs b/src/lib.rs index e1c713ef..b6e4d667 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -149,6 +149,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { api::list_artists, api::get_artist, api::list_artist_top_songs, + api::list_artist_albums, api::search_catalog, api::list_songs, api::list_random_songs, diff --git a/tests/enrichment.rs b/tests/enrichment.rs index 9e72cdef..60cc4b74 100644 --- a/tests/enrichment.rs +++ b/tests/enrichment.rs @@ -671,6 +671,35 @@ async fn a_foreign_artist_id_answers_as_a_missing_one() { assert_eq!(listener.fake.info.load(Ordering::SeqCst), 0); } +/// An artist's albums ask no provider; the artist's page does. +/// +/// `GET /api/v2/artists/{id}/albums` exists so an album page can list the +/// artist's other records without the enrichment the artist page carries. The +/// page is read here too, against the same engine, so a provider that is never +/// asked is the engine working and not a fake that cannot be reached. +#[tokio::test] +async fn an_artists_albums_ask_no_provider() { + let (_temp, _config, state, owner, _outsider) = two_members().await; + let listener = fake_lastfm(Mode::Hit, Duration::ZERO).await; + let enrichment = engine( + &state, + &settings(vec![Provider::LastFm], &run_key()), + &listener, + ); + state.services.use_enrichment(enrichment.clone()); + let bjork = artist_id(&state, owner, "Björk").await; + + let albums = state.services.artist(owner, bjork).await.unwrap(); + enrichment.idle().await; + assert!(!albums.albums.is_empty()); + assert!(albums.enrichment.is_none()); + assert_eq!(listener.fake.info.load(Ordering::SeqCst), 0); + + state.services.artist_page(owner, bjork).await.unwrap(); + enrichment.idle().await; + assert_eq!(listener.fake.info.load(Ordering::SeqCst), 1); +} + /// Decision 11's four states, each where it holds, through the native page — /// the one surface that publishes them. #[tokio::test] diff --git a/tests/native_api.rs b/tests/native_api.rs index 4155f825..5de0f74d 100644 --- a/tests/native_api.rs +++ b/tests/native_api.rs @@ -1052,6 +1052,20 @@ async fn native_browse_endpoints_page_search_and_isolate_tenants() { assert_eq!(detail["albums"].as_array().unwrap().len(), 1); assert_eq!(detail["albums"][0]["title"], "Nocturne Bleue"); + // The same albums on their own, without the page around them: what an + // album page reads to list the artist's other records. + let albums_of = get( + format!("/api/v2/artists/{artist_id}/albums"), + owner_token.clone(), + ) + .await; + assert_eq!(albums_of.status(), StatusCode::OK); + let albums_of = json_body(albums_of).await; + assert_eq!( + albums_of, detail["albums"], + "the page's albums, one for one" + ); + // FTS5 folds diacritics, so an unaccented query still reaches "Écho Solaire". let found = get("/api/v2/search?q=echo".into(), owner_token.clone()).await; assert_eq!(found.status(), StatusCode::OK); @@ -1148,6 +1162,8 @@ async fn native_browse_endpoints_page_search_and_isolate_tenants() { for uri in [ format!("/api/v2/albums/{album_id}"), format!("/api/v2/artists/{artist_id}"), + format!("/api/v2/artists/{artist_id}/albums"), + format!("/api/v2/artists/{}/albums", uuid::Uuid::new_v4()), ] { let response = get(uri, intruder_token.clone()).await; assert_eq!( diff --git a/webapp/bun.lock b/webapp/bun.lock index 0bb21fc5..8716c6f5 100644 --- a/webapp/bun.lock +++ b/webapp/bun.lock @@ -5,6 +5,8 @@ "": { "name": "waveflow-webapp", "dependencies": { + "@fontsource-variable/dm-sans": "5", + "@fontsource/playfair-display": "5", "@noble/hashes": "2.4.0", "@tanstack/react-query": "^5.104.3", "@tanstack/react-router": "^1.170.44", @@ -67,6 +69,10 @@ "@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="], + "@fontsource-variable/dm-sans": ["@fontsource-variable/dm-sans@5.3.0", "", {}, "sha512-BpUG5bqePiDFMBM4ZtLSlPdAIOM990uB1dlXzIf4aw21yQR7BmykedLXXXMqGWsR18aMLOsbWccwJNh3ztTQaA=="], + + "@fontsource/playfair-display": ["@fontsource/playfair-display@5.3.0", "", {}, "sha512-uJSABalvdWc10fEPppQjQocamT21LEyRfzzWfRfqbkPz+sRv+kKpYDKyluBUskvJREkJTos9epj1WjQROGu99Q=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 55368bf2..5521903b 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[] = []; @@ -1040,6 +1045,10 @@ async function mockAuthenticatedApi(page: Page) { await route.fulfill({ json: artistPlayed }); return; } + if (url.pathname === "/api/v2/artists/artist-1/albums") { + await route.fulfill({ json: albums }); + return; + } if (url.pathname === "/api/v2/artists/artist-1") { artistAsks += 1; if (artistFailsAfterFirst && artistAsks > 1) { @@ -1166,6 +1175,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 +1332,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 +1490,54 @@ 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, +}) => { + // "More by" reads the artist's albums alone. The library listing would page + // through every album to keep six, and the artist's page asks a metadata + // provider; neither has any business being asked from here. + const listings: string[] = []; + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (path === "/api/v2/albums" || path === "/api/v2/artists/artist-1") { + listings.push(path); + } + }); + 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); + expect(listings).toEqual([]); + 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 +1974,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 +2615,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,74 +3144,188 @@ test("offers nothing in the sheet that the account may not reach", async ({ await expect(sheet.getByRole("link", { name: "Upload" })).toHaveCount(0); }); +/** 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[] = []; + 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 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"]); +}); + /** - * 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. + * A tab switch waits for its answer, but not for ever. * - * **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. + * Past a short deadline the destination is shown with its own wait. A server + * that never answered held the previous screen, with nothing but a busy tab + * to say anything was happening. */ -test("draws the skeleton on a first visit, and never again on a return", async ({ +test("shows the next tab with its wait when the answer is slow", 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, - }); - }); + // Never answered: the deadline is the only way out. + await page.route("**/api/v2/genres*", () => new Promise(() => {})); + await page.route("**/api/v2/albums*", (route) => + route.fulfill({ json: albums }), + ); + + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + const mobile = await page.locator(".mobile-navigation").isVisible(); + if (mobile) { + await page + .locator(".mobile-navigation") + .getByRole("button", { name: "More" }) + .click(); } + const genresLink = mobile + ? page.locator('.more-sheet a[href="/genres"]') + : page.locator(".primary-navigation").getByRole("link", { name: "Genres" }); + await genresLink.click(); + await expect(page).toHaveURL(/\/genres$/); + // The destination's own wait, which is what the visitor now looks at. + await expect(page.locator("main .skeleton-grid")).toBeVisible(); +}); + +/** + * The current tab's link still goes to its plain address. + * + * A click on the tab already open is how a second click cancels a switch + * still loading. With nothing loading, swallowing it left `/playing?view=lyrics` + * where it was, though the link says `/playing`. + */ +test("takes the current tab back to its plain address", async ({ page }) => { + await page.goto("/playing?view=lyrics"); + await expect(page).toHaveURL(/\/playing\?view=lyrics$/); + const navigation = (await page.locator(".mobile-navigation").isVisible()) + ? page.locator(".mobile-navigation") + : page.locator(".primary-navigation"); + await navigation.getByRole("link", { name: "Now playing" }).click(); + await expect(page).toHaveURL(/\/playing$/); +}); + +/** + * The random tab draws once. + * + * Its answer is held for no time at all, so a draw made ahead of the switch + * was thrown away before the page mounted, and the page drew again: two + * requests, and a wait for both. + */ +test("draws the random tab once when switching to it", async ({ page }) => { + let draws = 0; + await page.route("**/api/v2/songs/random*", async (route) => { + draws += 1; + await route.fulfill({ json: [track] }); + }); + await page.route("**/api/v2/albums*", (route) => + route.fulfill({ json: albums }), + ); await page.goto("/"); await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + if (await page.locator(".mobile-navigation").isVisible()) { + await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click(); + await page.locator('.more-sheet a[href="/random"]').click(); + } else { + await page.locator(".primary-navigation").getByRole("link", { name: "Random" }).click(); + } + await expect(page.getByRole("heading", { name: "Random" })).toBeVisible(); + await expect(page.locator(".songs")).toContainText("Army of Me"); + // Long enough for a second draw from the mount to have been asked. + await page.waitForTimeout(500); + expect(draws).toBe(1); +}); - 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"), - }; +/** + * A tab that is still loading does not overrule a later move. + * + * The switch waits for the destination's first answer, and the page stays + * usable meanwhile. Following a link in it during that wait is a newer + * decision than the tab: arriving on the tab afterwards took the visitor away + * from the album they had just opened. + */ +test("leaves a tab switch behind when the visitor moves on while it loads", async ({ + page, +}) => { + let answerGenres: (() => void) | undefined; + const genresReady = new Promise((resolve) => { + answerGenres = resolve; + }); + let genresAnswered = false; + await page.route("**/api/v2/genres*", async (route) => { + await genresReady; + await route.fulfill({ + json: [{ name: "Trip hop", song_count: 4, album_count: 1 }], + }); + genresAnswered = true; }); + await page.route("**/api/v2/albums*", (route) => + route.fulfill({ json: albums }), + ); - expect(seen).toEqual({ first: true, back: false, again: false }); - // And the reason: the return asked nothing. Three visits, two questions. - expect(asked).toEqual(["/api/v2/albums", "/api/v2/genres"]); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(); + const mobile = await page.locator(".mobile-navigation").isVisible(); + if (mobile) { + await page.locator(".mobile-navigation").getByRole("button", { name: "More" }).click(); + } + const genresLink = mobile + ? page.locator('.more-sheet a[href="/genres"]') + : page.locator(".primary-navigation").getByRole("link", { name: "Genres" }); + await genresLink.click(); + await expect(genresLink).toHaveAttribute("aria-busy", "true"); + + await page.locator('.grid a[href="/albums/album-1"]').click(); + await expect(page).toHaveURL(/\/albums\/album-1$/); + + answerGenres?.(); + await expect.poll(() => genresAnswered).toBe(true); + await expect(genresLink).not.toHaveAttribute("aria-busy", "true"); + // Long enough for the navigation the answer used to trigger to land. + await page.waitForTimeout(500); + await expect(page).toHaveURL(/\/albums\/album-1$/); + await expect(page.getByRole("heading", { name: "Genres" })).toHaveCount(0); }); /** diff --git a/webapp/package.json b/webapp/package.json index 72f9035d..8547f649 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -15,6 +15,8 @@ "format": "biome check --write ." }, "dependencies": { + "@fontsource-variable/dm-sans": "5", + "@fontsource/playfair-display": "5", "@noble/hashes": "2.4.0", "@tanstack/react-query": "^5.104.3", "@tanstack/react-router": "^1.170.44", diff --git a/webapp/src/api/catalog.ts b/webapp/src/api/catalog.ts index 8f844d1b..73bb4c84 100644 --- a/webapp/src/api/catalog.ts +++ b/webapp/src/api/catalog.ts @@ -189,6 +189,16 @@ export const listArtists = (libraryId?: string) => export const getArtist = (id: string) => call(`/api/v2/artists/${id}`); +/** + * The albums credited to an artist, without the artist's page. + * + * Not {@link getArtist}, whose answer carries the artist's enrichment: asking + * for it schedules a provider fill, which an album page listing the artist's + * other records has no reason to cause. + */ +export const listArtistAlbums = (id: string) => + call(`/api/v2/artists/${id}/albums`); + /** * An artist’s most played tracks, for this account. * diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 3ecf3437..1b0b5abe 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -39,6 +39,7 @@ export { getArtist, getTrack, listAlbums, + listArtistAlbums, listArtists, listGenreSongs, listGenres, diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index 8e50c274..471e7e92 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -13,6 +13,7 @@ import { getTrackOverrides, listAlbums, listApiTokens, + listArtistAlbums, listArtists, listBookmarks, listFavorites, @@ -131,6 +132,13 @@ export const artistQuery = (artistId: string) => ), }); +/** An artist's albums alone, for the album page's "more by". */ +export const artistAlbumsQuery = (artistId: string) => + queryOptions({ + queryKey: ["artist-albums", artistId], + queryFn: () => listArtistAlbums(artistId), + }); + export const artistTopSongsQuery = (artistId: string) => queryOptions({ queryKey: ["artist-top-songs", artistId], diff --git a/webapp/src/components/cover-light.tsx b/webapp/src/components/cover-light.tsx new file mode 100644 index 00000000..cf5d969b --- /dev/null +++ b/webapp/src/components/cover-light.tsx @@ -0,0 +1,47 @@ +import { useEffect } from "react"; + +import { artworkUrl } from "../api"; +import { glowOf, sampleCover } from "../lib/cover-light"; +import { usePlayer } from "../player/player"; + +/** + * Lights the page with the playing record's cover. + * + * Writes `--glow` and `--glow-deep` on the document root, where the page's + * atmosphere and the player bar read them. Only the dark themes use them: the + * Fluid Editorial direction is dark-only, and a light theme keeps the ground + * it has. + * + * Kept when playback stops — the guide's light is the playing *or last-played* + * cover — and left alone when a cover cannot be sampled, so the page keeps the + * previous light rather than flashing back to the default. + */ +export function CoverLight() { + const artworkId = usePlayer().current?.artwork_hash ?? null; + + useEffect(() => { + if (!artworkId) return; + let cancelled = false; + void (async () => { + try { + const url = await artworkUrl(artworkId); + if (!url || cancelled) return; + const colour = await sampleCover(url); + // A sample that lands after the track changed must not write: it + // would light the page with the previous record. + if (!colour || cancelled) return; + const { glow, deep } = glowOf(colour); + const root = document.documentElement.style; + root.setProperty("--glow", glow); + root.setProperty("--glow-deep", deep); + } catch { + // A cover that cannot be decoded keeps the light already there. + } + })(); + return () => { + cancelled = true; + }; + }, [artworkId]); + + return null; +} diff --git a/webapp/src/components/page-shell.tsx b/webapp/src/components/page-shell.tsx index 611fc644..6f67ade3 100644 --- a/webapp/src/components/page-shell.tsx +++ b/webapp/src/components/page-shell.tsx @@ -108,10 +108,13 @@ export function Loading({ error }: { error: unknown }) { } export function PageHeader({ + kicker, title, detail, children, }: { + /** The rail section the page belongs to, set over the title. */ + kicker?: string; title: string; /** Omitted while the count it would state is not known yet. */ detail?: string; @@ -120,6 +123,7 @@ export function PageHeader({ return (
+ {kicker ? {kicker} : null}

{title}

{detail ?

{detail}

: null}
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..99737535 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,23 @@ export function SongTable({ return (
- +
+ {variant === "album" ? ( + + + + + + + + + ) : null} {songs.map((song, position) => { const starred = stars[song.id] ?? song.starred_at !== null; @@ -155,19 +173,27 @@ 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} +
+ # + {t("album.title")} + {t("album.duration")} + + {t("album.trackActions")} +
{song.artist}{formatDuration(song.duration_ms)}{song.artist} + {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 cb596666..34cbf3d4 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -1,4 +1,8 @@ -import { QueryClientProvider } from "@tanstack/react-query"; +import { + type QueryClient, + QueryClientProvider, + useQueryClient, +} from "@tanstack/react-query"; import { createRootRoute, createRoute, @@ -9,7 +13,14 @@ import { redirect, useNavigate, } from "@tanstack/react-router"; -import { type RefObject, StrictMode, useEffect, useRef } from "react"; +import { + type MouseEvent, + type RefObject, + StrictMode, + useEffect, + useRef, + useState, +} from "react"; import { createRoot } from "react-dom/client"; import { @@ -18,7 +29,24 @@ import { forgetOnSessionChange, logout, } from "./api"; +import { + administrationQuery, + albumsQuery, + artistsQuery, + favoriteTracksQuery, + genresQuery, + historyTracksQuery, + ownApiTokensQuery, + ownSubsonicCredentialQuery, + playlistsQuery, + radioStationsQuery, + scrobbleRowsQuery, + sharesQuery, + songsQuery, + 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"; @@ -56,6 +84,31 @@ import { } from "./state/library-scope"; 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: @@ -82,40 +135,168 @@ 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"]; + +/** + * How long a tab switch holds the current screen while the next one loads. + * + * Long enough that an ordinary answer arrives first and the switch shows no + * skeleton; short enough that a server slow to answer leaves the visitor on + * the screen they asked for, with its wait, rather than on the old one. + */ +const TAB_WAIT_MS = 1500; + +/** Have the destination's first answer ready before replacing the current tab. */ +async function prepareTab( + to: NavigationTarget, + scope: string | undefined, + client: QueryClient, +): Promise { + 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 "/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; + // A fresh draw on every ask, held for no time at all: a draw made here + // is thrown away before the page mounts, and the page then draws again. + // Waiting for it would double the wait for nothing. + case "/random": + break; + } +} + function Brand() { const { t } = useI18n(); return ( @@ -148,6 +329,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,22 +343,88 @@ 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; + } + if (window.location.pathname === to) { + // A second click on the current tab cancels a destination still loading. + // With nothing loading it is an ordinary link: it takes the tab back to + // its plain address, `/playing?view=lyrics` to `/playing`. + if (pendingTab === null) return; + event.preventDefault(); + navigationAttempt.current += 1; + setPendingTab(null); + return; + } + event.preventDefault(); + const attempt = ++navigationAttempt.current; + const origin = window.location.pathname + window.location.search; + setPendingTab(to); + void (async () => { + try { + // Waited for, but not for ever: past the deadline the destination + // shows its own wait, which says more than a busy tab does. + await Promise.race([ + prepareTab(to, active?.id, client), + new Promise((resolve) => setTimeout(resolve, TAB_WAIT_MS)), + ]); + } 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); + // The visitor moved on while this loaded — a link in the page, Back — + // and that move is the later decision. Arriving now would undo it. + if (window.location.pathname + window.location.search !== origin) return; + await navigate({ to }); + })(); + } + + 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) => ( + + + {album.title} + {album.year ? {album.year} : null} + + ))} +
+
+ ) : 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 59d1a733..d4a27c9a 100644 --- a/webapp/src/pages/playing-page.tsx +++ b/webapp/src/pages/playing-page.tsx @@ -497,6 +497,7 @@ export function PlayingPage() { return (
@@ -554,32 +555,45 @@ export function PlayingPage() { )} {bookmarks?.length ? ( -
-
+
+
-

{t("bookmarks.title")}

- {t("bookmarks.detail")} +

+ {t("bookmarks.title")} +

+

+ {t("bookmarks.detail")} +

-
    +
      {bookmarks.map((bookmark) => (
    • - + {formatDuration(bookmark.position_ms)} - - {song.artist ?? t("common.unknownArtist")} - - + {formatDuration(song.duration_ms)} 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 a7cad07b..81492094 100644 --- a/webapp/src/pages/settings-page.tsx +++ b/webapp/src/pages/settings-page.tsx @@ -353,8 +353,12 @@ function SubsonicPassword() { 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 22c6efbc..1d5f04b5 100644 --- a/webapp/src/pages/songs-page.tsx +++ b/webapp/src/pages/songs-page.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import type { SongSort } from "../api"; import { songsQuery } from "../api/queries"; +import { Icon } from "../components/icons"; import { Loading, PageHeader } from "../components/page-shell"; import { SongTable } from "../components/song-table"; import { usePlayer } from "../player/player"; @@ -58,26 +59,42 @@ export function SongsPage() { const songs = useMemo(() => data?.pages.flat() ?? [], [data]); const controls = ( - + <> + + {songs.length ? ( + + ) : null} + ); if (!data) { return (
      - {controls} + + {controls} +
      ); @@ -89,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. */} {controls} -
      - -
      {songs.length ? ( ) : ( diff --git a/webapp/src/pages/upload-page.tsx b/webapp/src/pages/upload-page.tsx index 50ada9c6..4ea42793 100644 --- a/webapp/src/pages/upload-page.tsx +++ b/webapp/src/pages/upload-page.tsx @@ -336,7 +336,7 @@ export function UploadPage() { if (!active) { return (
      - +

      {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)} +