diff --git a/docs/web-client-gap-analysis.md b/docs/web-client-gap-analysis.md index 1da2058..c582d4f 100644 --- a/docs/web-client-gap-analysis.md +++ b/docs/web-client-gap-analysis.md @@ -223,10 +223,21 @@ entièrement stylée à la main. C'est le bord non fini le plus visible de l'interface. **La typographie, décidée une fois.** Le serif d'affichage est le seul geste qui -ne soit pas générique, et il est orphelin : il sert sur l'écran de connexion, -que personne ne regarde, et nulle part ensuite. Deux issues cohérentes — en -faire un système, en le portant sur les titres de page ; ou l'abandonner. La -seule mauvaise décision est de le laisser où il est. +ne soit pas générique. + +> **Rectifié le 2026-09-06.** « Il est orphelin : il sert sur l'écran de +> connexion et nulle part ensuite » est faux, et c'est la cinquième affirmation +> de ce document à ne pas résister à la lecture du code. Le serif porte +> `h1, h2, h3` — donc **tous les titres de page** —, la marque de la barre +> latérale, la lettre qui remplace une pochette absente et le code de la page +> 404. Il était déjà un système ; ce qui le faisait passer pour un accident, +> c'est qu'il était déclaré **quatre fois, en deux orthographes** — trois des +> quatre omettaient « Palatino Linotype », donc le repli n'était pas le même +> selon l'écran regardé. +> +> Décision de l'opérateur : en faire un système. Concrètement il ne restait +> qu'à le nommer, `--font-display` et `--font-body` en tête de `styles.css`, +> une seule pile chacun. **Cette question est close.** **La carte dans la carte**, sur la connexion : deux rectangles arrondis emboîtés dont les fonds diffèrent de trois pour cent. C'est le signal @@ -276,6 +287,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 +324,19 @@ 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 sort du serif.**~~ **Tranché le 2026-09-06 : système.** Et la question + reposait sur une prémisse fausse — voir le geste rectifié plus haut. Il ne + restait qu'à nommer les deux piles au lieu de les répéter. +- ~~**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/tests/service.rs b/tests/service.rs index 8b62744..41f0594 100644 --- a/tests/service.rs +++ b/tests/service.rs @@ -207,6 +207,17 @@ async fn embedded_web_client_serves_shell_without_shadowing_the_api() { .unwrap() .starts_with("text/html")); + // A route parameter carrying an encoded slash still resolves to the shell. + // Genres are the case that raises it — "Rock/Pop" is one tag, and the + // client routes it as /genres/Rock%2FPop — so a link to one, shared or + // reloaded, must not decode into a two-segment path that matches nothing. + let encoded = get("/genres/Rock%2FPop").await; + assert_eq!(encoded.status(), StatusCode::OK); + assert!(encoded.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("text/html")); + // An unknown API path stays a JSON 404 instead of silently returning HTML. let missing_api = get("/api/v2/does-not-exist").await; assert_eq!(missing_api.status(), StatusCode::NOT_FOUND); diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index b924643..7a643e4 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -48,9 +48,59 @@ const track = { user_rating: null, }; +/** + * 120 distinct plays, newest first. More than the screen resolves, which is the + * point: each distinct track costs its own request. + */ +const history = Array.from({ length: 120 }, (_, index) => ({ + track_id: `t${index}`, + submission: true, + played_at: 1_000_000 - index, +})); + /** 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), + ], +}; + +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([ { @@ -71,8 +121,31 @@ async function mockAuthenticatedApi(page: Page) { await route.fulfill({ json: { ...albums[0], songs: [track] } }); return; } + if (url.pathname === "/api/v2/history") { + await route.fulfill({ json: history }); + return; + } + if (url.pathname.startsWith("/api/v2/tracks/")) { + const id = url.pathname.split("/")[4]; + await route.fulfill({ json: { ...song(1, `Track ${id}`, 0, false), id } }); + 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: { ...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") { @@ -228,8 +301,17 @@ test("keeps each card's actions guarded while its own fetch is out", async ({ await expect(slowPlay).toBeDisabled(); // The second album answers at once while the first is still held open. + // Synchronising on the response and not on the button is the point: the + // button is enabled before the click too, so `toBeEnabled` can resolve on + // its first poll, before React has even applied the disabling update — and + // the assertion below would then run at a moment when nothing has happened, + // which is exactly when the single-slot guard still looks correct. + const answered = page.waitForResponse( + (response) => new URL(response.url()).pathname === "/api/v2/albums/album-2", + ); await page.locator(".grid li").nth(1).hover(); await fastQueue.click(); + await answered; await expect(fastQueue).toBeEnabled(); // The first album has not answered, so its actions must still be refused. @@ -238,3 +320,102 @@ 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([]); +}); + +/** + * 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([]); +}); + +/** + * `GET /history` answers plays and not songs, so every distinct track on this + * screen is a request of its own, and they all leave together. Without a cap a + * long history meant a hundred-odd round trips for a list nobody reads to the + * bottom of. The window stays wide — 200 plays asked for — and only what is + * resolved is bounded. + */ +test("resolves a bounded number of tracks from a long history", async ({ + page, +}) => { + const asked: string[] = []; + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (path.startsWith("/api/v2/tracks/")) asked.push(path); + }); + + await page.goto("/history"); + await expect( + page.getByRole("heading", { name: "Recently played" }), + ).toBeVisible(); + + await expect(page.locator(".songs tbody tr")).toHaveCount(50); + expect(asked).toHaveLength(50); + // The newest plays are the ones kept, not an arbitrary fifty. + expect(asked[0]).toBe("/api/v2/tracks/t0"); + expect(asked.at(-1)).toBe("/api/v2/tracks/t49"); +}); 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..b8e1848 100644 --- a/webapp/src/i18n.tsx +++ b/webapp/src/i18n.tsx @@ -182,6 +182,36 @@ 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" }, + "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; @@ -361,6 +391,36 @@ 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" }, + "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 6328900..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: ( ), @@ -88,14 +131,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 ( + + ))} + + ); +} + +/** + * 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>({}); + const [ratings, setRatings] = useState>({}); async function toggleStar(song: Song) { const on = !(stars[song.id] ?? song.starred_at !== null); @@ -387,6 +455,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 (
@@ -396,7 +474,11 @@ export function SongTable({ songs }: { songs: Song[] }) { const active = player.current?.id === song.id; return ( - + - ); @@ -787,6 +877,395 @@ 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 ? ( + + ) : ( + + )} +
+ ); +} + +/** + * How many distinct tracks the recently-played screen resolves. + * + * Each one costs a request — `GET /history` answers plays, not songs — and they + * all leave at once. Asking for 200 plays and resolving every distinct track + * among them could mean 200 round trips for a list nobody reads to the bottom + * of. The window stays wide so that a session spent replaying one album still + * reaches back past it; only what is shown is capped. + */ +const HISTORY_TRACKS = 50; + +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.slice(0, HISTORY_TRACKS).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(); @@ -1067,7 +1546,7 @@ export function AlbumPage({ albumId }: { albumId: string }) {

- + ); } 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 5dab22c..0474562 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -28,6 +28,14 @@ --shadow: 0 1.5rem 4rem color-mix(in srgb, var(--bg) 76%, transparent); --sidebar-width: 15.5rem; + /* The two faces of the interface, named once. The serif is not the login + screen's ornament it was taken for: it carries every page title, the + brand, the letter standing in for a missing cover, and the 404. It was + spelled four different ways to say that, which is what kept it looking + like an accident. */ + --font-display: "Iowan Old Style", "Palatino Linotype", Georgia, serif; + --font-body: "Avenir Next", "Segoe UI Variable Text", "Segoe UI", sans-serif; + color-scheme: dark; font-synthesis: none; } @@ -74,11 +82,7 @@ body { radial-gradient(circle at 78% -12%, var(--accent-soft), transparent 35rem), var(--bg); color: var(--text); - font: - 500 0.94rem / 1.55 "Avenir Next", - "Segoe UI Variable Text", - "Segoe UI", - sans-serif; + font: 500 0.94rem / 1.55 var(--font-body); text-rendering: optimizelegibility; } @@ -206,7 +210,7 @@ p { h1, h2, h3 { - font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif; + font-family: var(--font-display); font-weight: 600; letter-spacing: -0.035em; } @@ -277,7 +281,7 @@ h3 { gap: 0.7rem; min-height: 2.6rem; padding-inline: 0.45rem; - font-family: "Iowan Old Style", Georgia, serif; + font-family: var(--font-display); font-size: 1.2rem; font-weight: 600; letter-spacing: -0.03em; @@ -286,7 +290,7 @@ h3 { .brand small { margin-left: auto; color: var(--muted); - font-family: "Avenir Next", sans-serif; + font-family: var(--font-body); font-size: 0.58rem; font-weight: 700; letter-spacing: 0.12em; @@ -429,7 +433,7 @@ h3 { right: -0.4rem; bottom: -2rem; color: var(--accent-soft); - font-family: "Iowan Old Style", Georgia, serif; + font-family: var(--font-display); font-size: 12rem; font-weight: 700; line-height: 1; @@ -818,7 +822,7 @@ main { } .cover-fallback span { - font-family: "Iowan Old Style", Georgia, serif; + font-family: var(--font-display); font-size: clamp(2rem, 5vw, 4.5rem); font-weight: 600; opacity: 0.75; @@ -933,6 +937,7 @@ main { .songs td { padding: 0.55rem 0.65rem; border-bottom: 1px solid var(--line); + vertical-align: middle; } .songs .index { @@ -983,14 +988,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); } @@ -1494,3 +1575,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; + } +}
{song.track ?? position + 1} + {numbering === "track" + ? (song.track ?? position + 1) + : position + 1} + {song.artist} {formatDuration(song.duration_ms)} - + +
+ void rate(song, rating)} + /> + +