From ef5da5ae29d2c1257879c039ec00681c797be38d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 21:47:11 +0200 Subject: [PATCH 1/6] feat(webapp): wire five-star ratings onto the song table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT /api/v2/ratings/{type}/{id}` and `Song.user_rating` have both been there since the native API landed; nothing on screen read either. The rating is a radio group, not five buttons. "Exactly one of five" is what a radio group means, it gives arrow-key selection without writing any, and each group is named after its row — one shared name would make every rating on the page a single choice. Clicking the star already set sends `rating: 0`, which is how the server spells "no rating"; without it a rating could be changed but never withdrawn. The favourite becomes a heart. It was a star, and putting five more stars beside it would have made one control out of two questions — "keep this" and "how good is it". The sidebar has called favourites a heart all along. Two things the screen taught: a `td` set to `display: flex` stops being a table cell, and the row borders drift out of line — the flex box is now an inner div. And the accessibility sweep had only ever loaded the albums grid, which has no song table, so it had never seen a form control in a row; it now visits an album too. Claude-Session: https://claude.ai/code/session_01TyKunaKXS16hyFDBHwc5KK Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 72 +++++++++++++++++++++- webapp/src/api.ts | 99 ++++++++++++++++++++++++++++++ webapp/src/i18n.tsx | 4 ++ webapp/src/icons.tsx | 13 +++- webapp/src/pages.tsx | 86 ++++++++++++++++++++++---- webapp/src/styles.css | 77 +++++++++++++++++++++++ 6 files changed, 336 insertions(+), 15 deletions(-) diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index b924643..449bab2 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -51,6 +51,36 @@ const track = { /** Resolved by default; one test replaces it to stall `album-1`. */ let slowAlbum: Promise = Promise.resolve(); +const song = ( + index: number, + title: string, + rating: number, + starred: boolean, +) => ({ + id: `song-${index}`, + library_id: "library-1", + album_id: "album-2", + title, + album: "Vespertine", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + duration_ms: 300_000, + track: index, + disc: 1, + starred_at: starred ? 1 : null, + user_rating: rating, +}); + +const albumDetail = { + ...albums[1], + songs: [ + song(1, "Hidden Place", 5, true), + song(2, "Cocoon", 0, false), + song(3, "Undo", 2, false), + ], +}; + async function mockAuthenticatedApi(page: Page) { await page.context().addCookies([ { @@ -72,7 +102,11 @@ async function mockAuthenticatedApi(page: Page) { return; } if (url.pathname === "/api/v2/albums/album-2") { - await route.fulfill({ json: { ...albums[1], songs: [track] } }); + await route.fulfill({ json: albumDetail }); + return; + } + if (url.pathname.startsWith("/api/v2/ratings/")) { + await route.fulfill({ status: 204, body: "" }); return; } if (url.pathname === "/api/v2/albums") { @@ -238,3 +272,39 @@ test("keeps each card's actions guarded while its own fetch is out", async ({ releaseSlow(); await expect(slowPlay).toBeEnabled(); }); + +/** + * 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 + * shows one. Until this test the sweep only ever loaded the grid. + */ +test("rates a track and stays free of WCAG A or AA violations", async ({ + page, +}) => { + const rated: string[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.startsWith("/api/v2/ratings/")) { + rated.push(`${request.method()} ${url.pathname}`); + } + }); + + await page.goto("/albums/album-2"); + await expect(page.getByRole("heading", { name: "Vespertine" })).toBeVisible(); + + // The rating is a radio group, so the stored value is a checked radio rather + // than a class on a span. + const hidden = page.getByRole("group", { name: "Rating: Hidden Place" }); + await expect(hidden.getByRole("radio", { name: "5 stars" })).toBeChecked(); + const cocoon = page.getByRole("group", { name: "Rating: Cocoon" }); + await expect(cocoon.getByRole("radio", { checked: true })).toHaveCount(0); + + await cocoon.getByRole("radio", { name: "4 stars" }).check(); + await expect(cocoon.getByRole("radio", { name: "4 stars" })).toBeChecked(); + expect(rated).toEqual(["PUT /api/v2/ratings/track/song-2"]); + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); +}); diff --git a/webapp/src/api.ts b/webapp/src/api.ts index ce03c76..baa5620 100644 --- a/webapp/src/api.ts +++ b/webapp/src/api.ts @@ -447,6 +447,105 @@ export const setFavorite = (kind: string, id: string, on: boolean) => method: on ? "PUT" : "DELETE", }); +/** The three things a star, a rating or a bookmark can hang off. */ +export type EntityKind = "track" | "album" | "artist"; + +export type Rating = { + entity_type: string; + entity_id: string; + rating: number; + updated_at: number; +}; + +export const listRatings = () => call("/api/v2/ratings"); + +/** 1 to 5 stars; 0 clears the rating, which is the server's own convention. */ +export const setRating = (kind: EntityKind, id: string, rating: number) => + call(`/api/v2/ratings/${kind}/${id}`, { + method: "PUT", + body: JSON.stringify({ rating }), + }); + +export type LyricsLine = { start?: number; value: string }; + +export type StructuredLyrics = { + displayArtist: string | null; + displayTitle: string; + lang: string; + synced: boolean; + line: LyricsLine[]; +}; + +export type LyricsList = { + track_id: string; + structured_lyrics: StructuredLyrics[]; +}; + +export const getLyrics = (trackId: string) => + call(`/api/v2/tracks/${trackId}/lyrics`); + +export type Bookmark = { + position_ms: number; + comment: string | null; + created_at: number; + updated_at: number; + song: Song; +}; + +export const listBookmarks = () => call("/api/v2/bookmarks"); + +/** + * One bookmark per account and track, so this replaces rather than adds — the + * route is `PUT` for that reason, and sending the same position twice leaves + * the same single bookmark. + */ +export const setBookmark = ( + trackId: string, + positionMs: number, + comment?: string, +) => + call(`/api/v2/bookmarks/${trackId}`, { + method: "PUT", + body: JSON.stringify({ + position_ms: Math.max(0, Math.round(positionMs)), + comment: comment ?? null, + }), + }); + +export const deleteBookmark = (trackId: string) => + call(`/api/v2/bookmarks/${trackId}`, { method: "DELETE" }); + +export type Genre = { + name: string; + song_count: number; + album_count: number; +}; + +export const listGenres = () => call("/api/v2/genres"); + +export const listGenreSongs = (genre: string) => + collect("/api/v2/songs", { genre }); + +/** + * `GET /history` answers plays, not songs — `track_id`, `submission` and + * `played_at` — so a screen wanting titles resolves them itself. The default + * limit is the server's 200; the cap is `MAX_SYNC_LIMIT`. + */ +export type Play = { + track_id: string; + submission: boolean; + played_at: number; +}; + +export const listHistory = (limit = 100) => + call(`/api/v2/history?limit=${limit}`); + +export const listRandomSongs = (limit = 100, genre?: string) => { + const query = new URLSearchParams({ limit: String(limit) }); + if (genre) query.set("genre", genre); + return call(`/api/v2/songs/random?${query}`); +}; + export const scrobble = (trackId: string, submission: boolean) => call("/api/v2/scrobbles", { method: "POST", diff --git a/webapp/src/i18n.tsx b/webapp/src/i18n.tsx index 4a217d3..a325652 100644 --- a/webapp/src/i18n.tsx +++ b/webapp/src/i18n.tsx @@ -182,6 +182,8 @@ const en = { "browse.noMatch": "Nothing matches that filter.", "card.play": "Play", "card.queue": "Add to queue", + "rating.label": "Rating", + "rating.stars": { one: "{count} star", other: "{count} stars" }, } as const; export type TranslationKey = keyof typeof en; @@ -361,6 +363,8 @@ const fr: Record = { "browse.noMatch": "Aucun résultat pour ce filtre.", "card.play": "Lire", "card.queue": "Mettre en file", + "rating.label": "Note", + "rating.stars": { one: "{count} étoile", other: "{count} étoiles" }, }; export type Locale = "en" | "fr"; diff --git a/webapp/src/icons.tsx b/webapp/src/icons.tsx index 6328900..7991002 100644 --- a/webapp/src/icons.tsx +++ b/webapp/src/icons.tsx @@ -88,14 +88,23 @@ const paths: Record = { ), }; -export function Icon({ name, size = 20 }: { name: IconName; size?: number }) { +export function Icon({ + name, + size = 20, + filled = false, +}: { + name: IconName; + size?: number; + /** Fills the glyph instead of outlining it, for on/off pairs like a heart. */ + filled?: boolean; +}) { return ( + + ))} + + ); +} + export function SongTable({ songs }: { songs: Song[] }) { const player = usePlayer(); const { t } = useI18n(); const [stars, setStars] = useState>({}); + const [ratings, setRatings] = useState>({}); async function toggleStar(song: Song) { const on = !(stars[song.id] ?? song.starred_at !== null); @@ -387,6 +431,16 @@ export function SongTable({ songs }: { songs: Song[] }) { } } + async function rate(song: Song, rating: number) { + const previous = ratings[song.id] ?? song.user_rating ?? 0; + setRatings((current) => ({ ...current, [song.id]: rating })); + try { + await setRating("track", song.id, rating); + } catch { + setRatings((current) => ({ ...current, [song.id]: previous })); + } + } + return (
@@ -416,18 +470,26 @@ export function SongTable({ songs }: { songs: Song[] }) { - ); diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 5dab22c..019f137 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -933,6 +933,7 @@ main { .songs td { padding: 0.55rem 0.65rem; border-bottom: 1px solid var(--line); + vertical-align: middle; } .songs .index { @@ -983,14 +984,90 @@ button.star:hover { } button.star { + display: grid; color: var(--muted); font-size: 1.15rem; + place-items: center; } button[aria-pressed="true"].star { color: var(--accent); } +/* Favourite and rating share a cell. They are different questions — "keep + this" and "how good is it" — and the table has no room for two columns. + The flex box is the inner div, not the cell: a `td` set to `display: flex` + stops being a table cell and its row's borders drift out of line. */ +.song-marks { + width: 1%; + white-space: nowrap; +} + +.song-marks > div { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; +} + +/* A radio group, so one of five is the shape the assistive layer sees and + arrow keys select. The inputs stay in the accessibility tree and out of the + picture; the stars beside them carry the paint. */ +.rating { + display: flex; + padding: 0; + border: 0; + margin: 0; + gap: 0.05rem; +} + +.rating label { + position: relative; + display: grid; + color: color-mix(in srgb, var(--muted) 45%, transparent); + cursor: pointer; + font-size: 0.95rem; + line-height: 1; + place-items: center; +} + +.rating label.on { + color: var(--accent); +} + +.rating:hover label { + color: color-mix(in srgb, var(--muted) 70%, transparent); +} + +.rating input { + position: absolute; + min-height: 0; + width: 100%; + height: 100%; + padding: 0; + border: 0; + margin: 0; + appearance: none; + background: none; + cursor: pointer; + inset: 0; +} + +.rating input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* The stars a hovered rating would set, so the target is visible before the + click commits it. `:has` walks back up from the hovered label. */ +.rating:has(label:nth-child(1):hover) label:nth-child(-n + 1), +.rating:has(label:nth-child(2):hover) label:nth-child(-n + 2), +.rating:has(label:nth-child(3):hover) label:nth-child(-n + 3), +.rating:has(label:nth-child(4):hover) label:nth-child(-n + 4), +.rating:has(label:nth-child(5):hover) label:nth-child(-n + 5) { + color: var(--accent); +} + button.danger { color: var(--danger); } From 1c49ccf42da085da952d79ac96d6b6646794b583 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 21:54:50 +0200 Subject: [PATCH 2/6] feat(webapp): give genres, history, random and lyrics their screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the eleven routes the plan lists as served-but-unused. None of them needed a line of server code. **Genres** get a two-column list and a page each. The genre travels into `GET /songs?genre=` as a display string, which is correct: the server canonicalises before matching, so "Hip-Hop" and "hip hop" are one genre there. A genre page also offers `GET /songs/random?genre=`, which is a different thing from shuffling the listing — it samples the whole genre rather than the page that happens to be loaded. **Recently played** resolves `GET /history`. The route answers plays and not songs — `track_id`, `submission`, `played_at` — and the same track recurs, so the first sighting of each id wins and each track is asked for once. What is lost is the repetition; what is kept is a playable list in the order things were last heard. **Random** is its own page with a redraw, because it is what a music server is for when nothing in particular is wanted. **Now playing** shows the cover, the track, and the lyrics that travel with the file. A synced sheet follows the head: `currentLyricLine` is the line already begun and not yet succeeded, and it is unit-tested because an off-by-one there shows a line before it is sung. An unsynced sheet has no starts and highlights nothing, rather than pretending to a timeline it does not have. **Saved positions** sit under it. One bookmark per track, replaced not added, so the button reads "save" whether or not one exists and saving again simply moves it. The sidebar gained four entries, which incidentally closes most of the gap the plan called "the sidebar void" — the honest fix, since the space wanted content and this is content that exists. Verified: biome, tsc, 38 unit tests, 12 Playwright tests, with the WCAG sweep now covering the genres list and a genre page as well. Claude-Session: https://claude.ai/code/session_01TyKunaKXS16hyFDBHwc5KK Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 55 ++++ webapp/src/i18n.tsx | 56 +++++ webapp/src/icons.tsx | 43 ++++ webapp/src/lyrics.test.ts | 58 +++++ webapp/src/main.tsx | 51 ++++ webapp/src/pages.tsx | 392 ++++++++++++++++++++++++++++- webapp/src/player.tsx | 3 +- webapp/src/styles.css | 106 ++++++++ 8 files changed, 762 insertions(+), 2 deletions(-) create mode 100644 webapp/src/lyrics.test.ts diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 449bab2..5b7c3d7 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -81,6 +81,16 @@ const albumDetail = { ], }; +const genres = [ + { name: "Art Pop", song_count: 412, album_count: 31 }, + { name: "Shoegaze", song_count: 233, album_count: 18 }, +]; + +const genreSongs = [ + song(1, "Hidden Place", 5, true), + song(2, "Cocoon", 0, false), +]; + async function mockAuthenticatedApi(page: Page) { await page.context().addCookies([ { @@ -101,6 +111,16 @@ async function mockAuthenticatedApi(page: Page) { await route.fulfill({ json: { ...albums[0], songs: [track] } }); return; } + if (url.pathname === "/api/v2/genres") { + await route.fulfill({ json: genres }); + return; + } + if (url.pathname === "/api/v2/songs") { + await route.fulfill({ + json: url.searchParams.get("genre") ? genreSongs : [], + }); + return; + } if (url.pathname === "/api/v2/albums/album-2") { await route.fulfill({ json: albumDetail }); return; @@ -308,3 +328,38 @@ test("rates a track and stays free of WCAG A or AA violations", async ({ .analyze(); expect(results.violations).toEqual([]); }); + +/** + * Genres were a route the server answered and the client never called. The + * navigation is the part worth pinning: the genre name travels into the query, + * and it is a display string — "Hip-Hop" and "hip hop" are one genre to the + * server, which canonicalises before matching. + */ +test("browses into a genre and keeps WCAG A and AA clean", async ({ page }) => { + const asked: Array = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname === "/api/v2/songs") { + asked.push(url.searchParams.get("genre")); + } + }); + + await page.goto("/genres"); + await expect(page.getByRole("heading", { name: "Genres" })).toBeVisible(); + await expect(page.getByText("412 tracks · 31 albums")).toBeVisible(); + + let results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); + + await page.getByRole("link", { name: "Art Pop" }).click(); + await expect(page.getByRole("heading", { name: "Art Pop" })).toBeVisible(); + await expect(page.locator(".songs tbody tr")).toHaveCount(2); + expect(asked).toEqual(["Art Pop"]); + + results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(results.violations).toEqual([]); +}); diff --git a/webapp/src/i18n.tsx b/webapp/src/i18n.tsx index a325652..b8e1848 100644 --- a/webapp/src/i18n.tsx +++ b/webapp/src/i18n.tsx @@ -184,6 +184,34 @@ const en = { "card.queue": "Add to queue", "rating.label": "Rating", "rating.stars": { one: "{count} star", other: "{count} stars" }, + "nav.genres": "Genres", + "nav.history": "Recently played", + "nav.random": "Random", + "nav.playing": "Now playing", + "browse.filterGenres": "Filter by genre", + "genres.detail": { one: "{count} genre", other: "{count} genres" }, + "genres.empty": "No genre is tagged in this library yet.", + "genres.emptyOne": "Nothing is tagged with this genre any more.", + "history.detail": { + one: "{count} track, most recent first", + other: "{count} tracks, most recent first", + }, + "history.empty": "Nothing has been played from this account yet.", + "random.detail": { + one: "{count} track drawn", + other: "{count} tracks drawn", + }, + "random.again": "Draw again", + "random.fromGenre": "Play this genre at random", + "random.empty": "There is nothing to draw from yet.", + "lyrics.label": "Lyrics", + "lyrics.none": "No lyrics travel with this file.", + "playing.idle": "Nothing is playing.", + "playing.empty": "Start a track and it will appear here, with its lyrics.", + "bookmarks.title": "Saved positions", + "bookmarks.detail": "One per track. Saving again moves it.", + "bookmarks.save": "Save this position", + "bookmarks.forget": "Forget", } as const; export type TranslationKey = keyof typeof en; @@ -365,6 +393,34 @@ const fr: Record = { "card.queue": "Mettre en file", "rating.label": "Note", "rating.stars": { one: "{count} étoile", other: "{count} étoiles" }, + "nav.genres": "Genres", + "nav.history": "Écoutes récentes", + "nav.random": "Aléatoire", + "nav.playing": "En écoute", + "browse.filterGenres": "Filtrer par genre", + "genres.detail": { one: "{count} genre", other: "{count} genres" }, + "genres.empty": "Aucun genre n’est encore étiqueté dans cette bibliothèque.", + "genres.emptyOne": "Plus rien ne porte ce genre.", + "history.detail": { + one: "{count} piste, la plus récente d’abord", + other: "{count} pistes, la plus récente d’abord", + }, + "history.empty": "Rien n’a encore été écouté depuis ce compte.", + "random.detail": { + one: "{count} piste tirée", + other: "{count} pistes tirées", + }, + "random.again": "Tirer à nouveau", + "random.fromGenre": "Lire ce genre au hasard", + "random.empty": "Il n’y a encore rien à tirer.", + "lyrics.label": "Paroles", + "lyrics.none": "Aucune parole ne voyage avec ce fichier.", + "playing.idle": "Rien n’est en cours de lecture.", + "playing.empty": "Lancez une piste et elle apparaîtra ici, avec ses paroles.", + "bookmarks.title": "Positions enregistrées", + "bookmarks.detail": "Une par piste. Enregistrer à nouveau la déplace.", + "bookmarks.save": "Enregistrer cette position", + "bookmarks.forget": "Oublier", }; export type Locale = "en" | "fr"; diff --git a/webapp/src/icons.tsx b/webapp/src/icons.tsx index 7991002..cf807f0 100644 --- a/webapp/src/icons.tsx +++ b/webapp/src/icons.tsx @@ -4,6 +4,10 @@ export type IconName = | "albums" | "artists" | "search" + | "genres" + | "random" + | "history" + | "lyrics" | "heart" | "playlists" | "queue" @@ -36,6 +40,45 @@ const paths: Record = { ), + // A disc with a tag through it: a genre is a label on a record, not a folder. + genres: ( + <> + + + + + ), + // The crossing arrows every player uses for shuffle. + random: ( + <> + + + + + + + + + ), + // A clock turning back, which is what a listening history is. + history: ( + <> + + + + + ), + // A quoted line over a staff: words carried by the music. + lyrics: ( + <> + + + + + + + + ), heart: ( ), diff --git a/webapp/src/lyrics.test.ts b/webapp/src/lyrics.test.ts new file mode 100644 index 0000000..947f13e --- /dev/null +++ b/webapp/src/lyrics.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { currentLyricLine } from "./pages"; + +/** + * The highlight in the now-playing view. Off-by-one here shows a line before + * it is sung or holds the previous one through it, which is the whole of what + * a synced sheet is for. + */ +describe("currentLyricLine", () => { + const sheet = [ + { start: 0, value: "one" }, + { start: 5_000, value: "two" }, + { start: 12_500, value: "three" }, + ]; + + it("holds a line until the next one starts", () => { + expect(currentLyricLine(sheet, 0)).toBe(0); + expect(currentLyricLine(sheet, 4.999)).toBe(0); + expect(currentLyricLine(sheet, 5)).toBe(1); + expect(currentLyricLine(sheet, 12.4)).toBe(1); + expect(currentLyricLine(sheet, 12.5)).toBe(2); + // Past the last start it stays on the last line rather than falling off. + expect(currentLyricLine(sheet, 600)).toBe(2); + }); + + it("highlights nothing before the first line", () => { + // A sheet whose first line starts late leaves an intro unhighlighted. + const late = [{ start: 3_000, value: "late" }]; + expect(currentLyricLine(late, 0)).toBe(-1); + expect(currentLyricLine(late, 2.9)).toBe(-1); + expect(currentLyricLine(late, 3)).toBe(0); + }); + + it("highlights nothing in a sheet that carries no times", () => { + // An unsynced sheet is a list of lines with no `start` at all. Every + // position has to answer -1, or a plain lyric sheet would follow a + // timeline it does not have. + const plain = [{ value: "a" }, { value: "b" }]; + expect(currentLyricLine(plain, 0)).toBe(-1); + expect(currentLyricLine(plain, 90)).toBe(-1); + }); + + it("stops at the first untimed line rather than skipping it", () => { + // LRC files exist with a stray untimed line; treating it as "no start, keep + // looking" would let a later line light up while this one is on screen. + const mixed = [ + { start: 0, value: "a" }, + { value: "b" }, + { start: 9_000, value: "c" }, + ]; + expect(currentLyricLine(mixed, 30)).toBe(0); + }); + + it("has nothing to highlight in an empty sheet", () => { + expect(currentLyricLine([], 10)).toBe(-1); + }); +}); diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index efc7a15..6889a91 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -27,10 +27,15 @@ import { ArtistsPage, AuthorizePage, FavoritesPage, + GenrePage, + GenresPage, + HistoryPage, LoginPage, NotFoundPage, + PlayingPage, PlaylistsPage, QueuePage, + RandomPage, SearchPage, SharesPage, } from "./pages"; @@ -42,8 +47,12 @@ const navigation: Array<{ to: | "/" | "/artists" + | "/genres" | "/search" | "/favourites" + | "/history" + | "/random" + | "/playing" | "/playlists" | "/queue" | "/shares" @@ -55,7 +64,11 @@ const navigation: Array<{ }> = [ { to: "/", labelKey: "nav.albums", icon: "albums", primary: true }, { to: "/artists", labelKey: "nav.artists", icon: "artists" }, + { 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: "/favourites", labelKey: "nav.favourites", @@ -241,6 +254,39 @@ const authorizeRoute = createRoute({ component: AuthorizePage, }); +const genresRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/genres", + component: GenresPage, +}); + +const genreRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/genres/$genre", + component: function GenreRoute() { + const { genre } = genreRoute.useParams(); + return ; + }, +}); + +const historyRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/history", + component: HistoryPage, +}); + +const randomRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/random", + component: RandomPage, +}); + +const playingRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/playing", + component: PlayingPage, +}); + const searchRoute = createRoute({ getParentRoute: () => authedRoute, path: "/search", @@ -287,6 +333,11 @@ const routeTree = rootRoute.addChildren([ albumRoute, artistsRoute, artistRoute, + genresRoute, + genreRoute, + historyRoute, + randomRoute, + playingRoute, searchRoute, favoritesRoute, playlistsRoute, diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index 740eebb..a60d9af 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -16,23 +16,34 @@ import { addLibrary, appendToPlaylist, authorize, + type Bookmark, bootstrapAdmin, createPlaylist, createShare, createUser, currentUser, + deleteBookmark, deletePlaylist, deleteShare, formatDuration, + type Genre, getAlbum, getArtist, + getLyrics, getTrack, isAllowedRedirect, + type LyricsLine, + type LyricsList, listAlbums, listArtists, + listBookmarks, listFavorites, + listGenreSongs, + listGenres, + listHistory, listLibraries, listPlaylists, + listRandomSongs, listShares, listUsers, login, @@ -42,6 +53,7 @@ import { type Song, safeInternalPath, search, + setBookmark, setFavorite, setRating, setSubsonicCredential, @@ -53,7 +65,7 @@ import { import { Artwork } from "./artwork"; import { type TranslationKey, useI18n } from "./i18n"; import { Icon } from "./icons"; -import { usePlayer } from "./player"; +import { usePlayer, usePlayerProgress } from "./player"; const SKELETON_KEYS = [ "one", @@ -849,6 +861,384 @@ function CredentialForm({ user }: { user: User }) { ); } +/** Plays a set of songs, or adds them to the queue, from a page header. */ +function PlaySetActions({ songs }: { songs: Song[] }) { + const player = usePlayer(); + const { t } = useI18n(); + return ( +
+ + +
+ ); +} + +export function GenresPage() { + const { t } = useI18n(); + const [filter, setFilter] = useState(""); + const { value, error } = useAsync(listGenres, []); + const needle = normalizeFilter(filter); + const shown = useMemo( + () => + !value || !needle + ? (value ?? []) + : value.filter((genre) => matches(genre.name, needle)), + [value, needle], + ); + if (!value) return ; + return ( +
+ + + + {value.length === 0 ? ( + + ) : shown.length === 0 ? ( + + ) : ( +
    + {shown.map((genre) => ( +
  • + + {genre.name} + + + {t("common.tracks", { count: genre.song_count })} ·{" "} + {t("common.albums", { count: genre.album_count })} + +
  • + ))} +
+ )} +
+ ); +} + +export function GenrePage({ genre }: { genre: string }) { + const player = usePlayer(); + const { t } = useI18n(); + const [drawing, setDrawing] = useState(false); + const { value, error } = useAsync( + () => listGenreSongs(genre), + [genre], + ); + + /** + * A shuffle of this genre, drawn by the server. It is a separate route from + * the listing below rather than a client-side shuffle of it, because + * `/songs/random` samples the whole genre and this page holds only what it + * has paged in. + */ + async function drawRandom() { + setDrawing(true); + try { + const songs = await listRandomSongs(100, genre); + if (songs.length) player.play(songs, 0); + } catch { + // The listing below is still playable; nothing to recover from. + } finally { + setDrawing(false); + } + } + + if (!value) return ; + return ( +
+ +
+ + +
+
+ {value.length ? ( + + ) : ( + + )} +
+ ); +} + +export function HistoryPage() { + const { t } = useI18n(); + const { value, error } = useAsync(async () => { + const plays = await listHistory(200); + // The route answers plays, not songs, and the same track can appear many + // times. Keeping the first sighting of each id gives "recently played" in + // the order it was last played, and asks for each track once. + const seen = new Set(); + const ordered = plays.filter((play) => { + if (seen.has(play.track_id)) return false; + seen.add(play.track_id); + return true; + }); + const resolved = await Promise.allSettled( + ordered.map((play) => getTrack(play.track_id)), + ); + return resolved.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + }, []); + if (!value) return ; + return ( +
+ + {value.length ? : null} + + {value.length ? ( + + ) : ( + + )} +
+ ); +} + +export function RandomPage() { + const { t } = useI18n(); + const [draw, setDraw] = useState(0); + const { value, error } = useAsync(() => listRandomSongs(100), [draw]); + if (!value) return ; + return ( +
+ +
+ + +
+
+ {value.length ? ( + + ) : ( + + )} +
+ ); +} + +/** + * The line a synced lyric sheet is on at `seconds`. Lines carry their own start + * in milliseconds and arrive in order, so this is the last one already begun; + * `-1` before the first. An unsynced sheet has no starts and never highlights. + */ +export function currentLyricLine(lines: LyricsLine[], seconds: number): number { + let current = -1; + for (const [index, line] of lines.entries()) { + if (line.start === undefined || line.start > seconds * 1000) break; + current = index; + } + return current; +} + +function Lyrics({ trackId }: { trackId: string }) { + const { t } = useI18n(); + const progress = usePlayerProgress(); + const { value, error } = useAsync( + () => getLyrics(trackId), + [trackId], + ); + const sheet = value?.structured_lyrics[0]; + const active = sheet?.synced + ? currentLyricLine(sheet.line, progress.position) + : -1; + // A refrain repeats word for word and an unsynced sheet has no start to key + // on, so the line's text alone is not unique. Numbering the repetitions is + // what the queue does with the same problem. + const keys = useMemo(() => { + const seen = new Map(); + return (sheet?.line ?? []).map((line) => { + const occurrence = seen.get(line.value) ?? 0; + seen.set(line.value, occurrence + 1); + return `${line.value}-${occurrence}`; + }); + }, [sheet]); + + if (error) return

{t("lyrics.none")}

; + if (!value) return

{t("common.loading")}

; + if (!sheet || sheet.line.length === 0) + return

{t("lyrics.none")}

; + return ( +
+

+ {sheet.displayTitle} + {sheet.displayArtist ? {sheet.displayArtist} : null} +

+
    + {sheet.line.map((line, index) => ( +
  1. + {line.value || " "} +
  2. + ))} +
+
+ ); +} + +export function PlayingPage() { + const player = usePlayer(); + const progress = usePlayerProgress(); + const { t } = useI18n(); + const [revision, setRevision] = useState(0); + const [busy, setBusy] = useState(false); + const { value: bookmarks } = useAsync(listBookmarks, [revision]); + const current = player.current; + + /** + * One bookmark per track, replaced rather than added — so this button reads + * "save" whether or not the track already has one, and saving again simply + * moves it to where the head is now. + */ + async function saveHere(song: Song) { + setBusy(true); + try { + await setBookmark(song.id, progress.position * 1000); + setRevision((n) => n + 1); + } catch { + // Nothing is lost: the head has not moved and the old bookmark stands. + } finally { + setBusy(false); + } + } + + async function forget(trackId: string) { + try { + await deleteBookmark(trackId); + setRevision((n) => n + 1); + } catch { + // Same: the list simply does not change. + } + } + + return ( +
+ + {current ? ( + + ) : null} + + + {current ? ( +
+ +
+ + {current.album ?? t("common.album")} + +

{current.title}

+

+ {current.artist ?? t("common.unknownArtist")} +

+ +
+
+ ) : ( + + )} + + {bookmarks?.length ? ( +
+
+
+

{t("bookmarks.title")}

+ {t("bookmarks.detail")} +
+
+
    + {bookmarks.map((bookmark) => ( +
  • + + + {formatDuration(bookmark.position_ms)} + + +
  • + ))} +
+
+ ) : null} +
+ ); +} + export function AdminPage() { const signedInUser = currentUser(); const { t } = useI18n(); diff --git a/webapp/src/player.tsx b/webapp/src/player.tsx index 02d93e0..d7e79e8 100644 --- a/webapp/src/player.tsx +++ b/webapp/src/player.tsx @@ -67,7 +67,8 @@ export function usePlayer(): PlayerState { return player; } -function usePlayerProgress(): PlayerProgress { +/** Position and duration in seconds, ticking as the element plays. */ +export function usePlayerProgress(): PlayerProgress { const progress = useContext(PlayerProgressContext); if (!progress) throw new Error("usePlayerProgress requires PlayerProvider"); return progress; diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 019f137..42f5940 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -1571,3 +1571,109 @@ button.danger { transition-duration: 0.01ms; } } + +/* Genres are a two-column list like artists: a name is short, and one column + of them wastes the width the grid pages use. */ +.genre-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 2rem; +} + +.genre-list a { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.genre-list a:hover strong { + color: var(--accent); +} + +/* Cover on the left, words on the right, stacking under the fold. */ +.playing { + display: flex; + align-items: flex-start; + gap: clamp(1.5rem, 4vw, 3.5rem); + margin-bottom: 2.4rem; +} + +.playing-detail { + min-width: 0; + flex: 1 1 auto; +} + +.playing-detail h2 { + margin: 0.35rem 0 0.2rem; + font-size: clamp(1.6rem, 3vw, 2.6rem); + line-height: 1.1; +} + +.lyrics { + margin-top: 1.8rem; +} + +.lyrics h3 { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin: 0 0 0.8rem; + font-size: 1rem; +} + +.lyrics h3 small { + color: var(--muted); + font-size: 0.76rem; +} + +.lyrics ol { + max-height: 26rem; + padding: 0; + margin: 0; + list-style: none; + overflow-y: auto; +} + +.lyrics li { + padding: 0.16rem 0; + color: var(--muted); + line-height: 1.5; + transition: color 180ms ease; +} + +/* The line a synced sheet is on. An unsynced sheet never sets it, so the + whole text stays evenly weighted rather than pretending to follow. */ +.lyrics li.on { + color: var(--text); + font-weight: 600; +} + +.bookmark-list li { + gap: 1rem; +} + +.bookmark-list .link { + display: grid; + min-width: 0; +} + +.bookmark-list .link small { + font-size: 0.72rem; +} + +@media (prefers-reduced-motion: reduce) { + .lyrics li { + transition: none; + } +} + +@media (width <= 52rem) { + .genre-list { + grid-template-columns: 1fr; + } + + .playing { + flex-direction: column; + } +} From 6f9f16a62d980c16f6c4533fbb2c048f26545e6e Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 6 Sep 2026 22:02:04 +0200 Subject: [PATCH 3/6] fix(webapp): number a song row by its place in the list it is in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A track's number belongs to its sleeve. `SongTable` was showing it everywhere — `song.track ?? position + 1` — so favourites, a playlist and a search result were labelling their rows with positions from albums that were not on screen. A recently-played list reading 3, 1, 2 is the clearest form of the defect. The default is now the row's place in the list being shown, and the album page, the one place a sleeve is on screen, asks for the track number explicitly. The plan is updated with what lot B branched and what it deliberately did not: the library selector is the first of the three open questions, and settling it in passing would decide what a browsing session is without saying so. The sidebar void is struck out — the four navigation entries this lot added closed it, which was the right order, since the space wanted content and the content now exists. Claude-Session: https://claude.ai/code/session_01TyKunaKXS16hyFDBHwc5KK Signed-off-by: InstaZDLL --- docs/web-client-gap-analysis.md | 28 +++++++++++++++++++++++----- webapp/src/pages.tsx | 22 +++++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/web-client-gap-analysis.md b/docs/web-client-gap-analysis.md index 1da2058..dde1828 100644 --- a/docs/web-client-gap-analysis.md +++ b/docs/web-client-gap-analysis.md @@ -276,6 +276,21 @@ de risque. historique, aléatoire, sélecteur de bibliothèque. Onze routes qui existent, un écran chacune ou presque. C'est ce qui ferme l'écart Navidrome. +> **État au 2026-09-06.** Six des sept sont branchés : notes cinq étoiles sur le +> tableau de pistes, genres (liste et page par genre), écoutes récentes, +> aléatoire, paroles et positions enregistrées dans une vue « en écoute ». +> **Le sélecteur de bibliothèque est laissé de côté volontairement** — c'est la +> première des questions ouvertes ci-dessous, et la trancher en passant +> reviendrait à décider la navigation du client web sans le dire. +> +> Trois choses apprises en branchant. `GET /songs?genre=` prend le genre en +> forme d'affichage et le canonicalise avant de comparer, donc « Hip-Hop » et +> « hip hop » sont un seul genre côté serveur. `GET /history` répond des +> **écoutes** et non des pistes — `track_id`, `submission`, `played_at` — donc +> un écran qui veut des titres les résout lui-même. Et le favori, jusque-là +> rendu par une étoile, est devenu un cœur : cinq étoiles de note à côté d'une +> étoile de favori auraient fait un seul contrôle de deux questions. + **Lot C — le lecteur et l'exploitation.** Volume, aléatoire, répétition, file accessible, retour vers l'album. Puis côté administration : progression de scan en direct, en écoute maintenant, membres, jetons. @@ -298,14 +313,17 @@ n'est pas sur ce chemin critique. - **Le sélecteur de bibliothèque** est le seul point où Navidrome est devant sur une fonction que WaveFlow possède. Reste à décider si la navigation web est cadrée par une bibliothèque à la fois, ou agrégée avec une bibliothèque comme - filtre. + filtre. **C'est le seul point du lot B qui n'a pas été branché**, et c'est + pour cette raison : le reste du lot ne demandait qu'un `fetch`, celui-ci + demande une décision sur ce qu'est une session de navigation. - **Ce qu'on montre d'une correction de tags** quand elle diverge du fichier : la valeur corrigée seule, ou les deux avec leur provenance. - **Le sort du serif.** Système ou abandon, mais pas le statu quo. -- **Le vide de la barre latérale**, entre la navigation en haut et les réglages - en bas. La question n'est pas comment le combler mais avec quoi : tout candidat - honnête est du lot B — genres, écoutes récentes, bibliothèque courante — et le - choix engage la navigation, pas seulement l'espace. +- ~~**Le vide de la barre latérale.**~~ **Refermé par le lot B**, sans avoir été + traité pour lui-même : les quatre entrées de navigation ajoutées — genres, en + écoute, aléatoire, écoutes récentes — occupent l'espace que le lot A avait + laissé vide. C'était le bon ordre : le vide voulait du contenu, et le contenu + a fini par exister. - **Les langues.** Deux aujourd'hui, trente-quatre chez Navidrome. La question n'est pas d'y arriver mais de savoir si l'infrastructure de `i18n.tsx` tient au-delà d'une poignée. diff --git a/webapp/src/pages.tsx b/webapp/src/pages.tsx index a60d9af..3719894 100644 --- a/webapp/src/pages.tsx +++ b/webapp/src/pages.tsx @@ -427,7 +427,19 @@ function StarRating({ ); } -export function SongTable({ songs }: { songs: Song[] }) { +/** + * A track's number belongs to its sleeve, so it is only meaningful where the + * sleeve is on screen. Everywhere else — favourites, a playlist, a genre, a + * history, a draw, a search — the number that means something is the row's + * place in the list being shown, which is why that is the default. + */ +export function SongTable({ + songs, + numbering = "position", +}: { + songs: Song[]; + numbering?: "track" | "position"; +}) { const player = usePlayer(); const { t } = useI18n(); const [stars, setStars] = useState>({}); @@ -462,7 +474,11 @@ export function SongTable({ songs }: { songs: Song[] }) { const active = player.current?.id === song.id; return (
- +
{song.artist} {formatDuration(song.duration_ms)} - + +
+ void rate(song, rating)} + /> + +
{song.track ?? position + 1} + {numbering === "track" + ? (song.track ?? position + 1) + : position + 1} +