From 7cc8f694670dca4801e9fb9264dc2c5f1e933adf Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 05:56:50 +0200 Subject: [PATCH 1/5] feat(webapp): open a track's properties from its context menu The first step of RFC-012. A right click on a song row opens a menu with play, add to queue, properties and, for owners and managers, edit tags. The same menu answers the context-menu key and Shift+F10 on anything focused in the row, and a long press on touch; it follows the ARIA menu pattern, closes on Escape, Tab, a click away, the wheel or a new touch moving the page, and gives focus back to the row it came from. Properties is a native dialog: the file's path in its library, format, bit rate, sample rate, depth, channels, size, duration, date added and content hash; the tags, each corrected one beside what the file said; ReplayGain and BPM; and which lyrics the track has. GET /api/v2/tracks/{id} gains relative_path, through a TrackDetail that flattens the song. Listings keep their shape: SongItem reads the path and never serialises it. The library root itself is never sent. Not the scroll event: focusing a row scrolls it into view on the next frame and a page still rising into place moves it, and closing on those shut the menu the instant it opened. Signed-off-by: InstaZDLL --- src/api/tracks.rs | 11 +- src/lib.rs | 1 + src/services/mod.rs | 22 +- tests/native_api.rs | 27 ++ webapp/e2e/studio-nocturne.spec.ts | 90 ++++++ webapp/src/api/index.ts | 2 + webapp/src/api/queries.ts | 21 ++ webapp/src/api/tracks.ts | 28 ++ webapp/src/components/icons.tsx | 11 +- webapp/src/components/song-table.tsx | 4 + webapp/src/components/track-menu.tsx | 354 +++++++++++++++++++++ webapp/src/components/track-properties.tsx | 302 ++++++++++++++++++ webapp/src/state/i18n.tsx | 98 ++++++ webapp/src/styles/fluid.css | 62 ++++ webapp/src/styles/styles.css | 132 ++++++++ 15 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 webapp/src/components/track-menu.tsx create mode 100644 webapp/src/components/track-properties.tsx diff --git a/src/api/tracks.rs b/src/api/tracks.rs index 36de9a5b..c9837b0f 100644 --- a/src/api/tracks.rs +++ b/src/api/tracks.rs @@ -69,12 +69,12 @@ pub async fn get_track_overrides( .map_err(service_error) } -#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::SongItem), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] +#[utoipa::path(get, path = "/api/v2/tracks/{track_id}", tag = "catalog", params(("track_id" = Uuid, Path)), responses((status = 200, body = crate::services::TrackDetail), (status = 401, body = ErrorResponse), (status = 404, body = ErrorResponse)))] pub async fn get_track( State(state): State, Path(track_id): Path, headers: HeaderMap, -) -> Result, ApiError> { +) -> Result, ApiError> { let user = authenticated(&state, &headers, Access::Read).await?; state .services @@ -83,7 +83,12 @@ pub async fn get_track( .map_err(service_error)? .into_iter() .next() - .map(Json) + .map(|song| { + Json(crate::services::TrackDetail { + relative_path: song.relative_path.clone(), + song, + }) + }) .ok_or(ApiError::NotFound) } diff --git a/src/lib.rs b/src/lib.rs index b6e4d667..80e98463 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -245,6 +245,7 @@ fn chunk_body_limit(limits: &config::UploadLimits) -> usize { services::GenreItem, services::LibraryMember, services::SongItem, + services::TrackDetail, services::TrackOverrides, services::TrackOverrideValues, services::TrackSourceTags, diff --git a/src/services/mod.rs b/src/services/mod.rs index e2cc2e79..4c85cb64 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -356,6 +356,25 @@ pub struct SongItem { pub moods: Vec, /// `explicit` or `clean`; the scanner stores nothing else. pub explicit_status: Option, + /// Where the file sits in its library, relative to the library root. + /// + /// Read with every song and serialised with none: listings would carry a + /// path per row that nobody browsing albums needs. `GET /tracks/{id}` + /// sends it, through [`TrackDetail`], for the properties panel and the + /// library browser (RFC-012 decision 1). The root itself is never sent. + #[serde(skip)] + pub relative_path: String, +} + +/// One track as `GET /api/v2/tracks/{id}` answers it: the song, and where its +/// file sits in the library. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TrackDetail { + #[serde(flatten)] + pub song: SongItem, + /// Relative to the library root, with `/` separators whatever the + /// server's platform. Never absolute. + pub relative_path: String, } /// One credited artist of a track. Only `id` and `name` are carried: those are @@ -1941,7 +1960,7 @@ fn song_from_row(row: sqlx::sqlite::SqliteRow) -> Result duration_ms: row.try_get("duration_ms")?, bitrate: row.try_get("bitrate")?, codec: row.try_get("codec")?, - suffix: PathBuf::from(relative) + suffix: PathBuf::from(&relative) .extension() .and_then(|value| value.to_str()) .unwrap_or("") @@ -1978,6 +1997,7 @@ fn song_from_row(row: sqlx::sqlite::SqliteRow) -> Result isrc: split_tag_values(row.try_get::, _>("isrc")?.as_deref()), moods: split_tag_values(row.try_get::, _>("moods")?.as_deref()), explicit_status: row.try_get("explicit_status")?, + relative_path: relative, }) } diff --git a/tests/native_api.rs b/tests/native_api.rs index 73032790..3383dab0 100644 --- a/tests/native_api.rs +++ b/tests/native_api.rs @@ -1127,6 +1127,33 @@ async fn native_browse_endpoints_page_search_and_isolate_tenants() { assert_eq!(track("Prélude")["artist_id"], artist_id); assert_eq!(track("Rivière Noire")["artist_id"], artist_id); + // One track carries where its file sits, relative to the library, as the + // library's own listing spells it; the album's song rows do not, so a + // listing is not a path per row nobody browsing albums asked for. + let prelude = track("Prélude"); + let single = get( + format!("/api/v2/tracks/{}", prelude["id"].as_str().unwrap()), + owner_token.clone(), + ) + .await; + assert_eq!(single.status(), StatusCode::OK); + let single = json_body(single).await; + assert_eq!(single["title"], "Prélude"); + assert_eq!(single["relative_path"], prelude["relative_path"]); + let path = single["relative_path"].as_str().unwrap(); + assert!(!path.is_empty() && !path.contains('\\'), "{path}"); + assert!( + !std::path::Path::new(path).is_absolute(), + "the library root never leaves the server: {path}" + ); + let album_songs = get(format!("/api/v2/albums/{album_id}"), owner_token.clone()).await; + let album_songs = json_body(album_songs).await; + assert!(album_songs["songs"] + .as_array() + .unwrap() + .iter() + .all(|song| song.get("relative_path").is_none())); + // Search-as-you-type: the trailing term matches as a prefix. This exact // case was reported from the Android client — "echo" returned songs, // albums and artists while "ech" returned nothing, because the native diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index f0ae6704..0150debb 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -1375,6 +1375,96 @@ test("puts the songs playback action beside the sort and names its scope", async await expect(page.locator(".player-track strong")).toHaveText("Army of Me"); }); +/** + * The right click on a track, and the properties it opens (RFC-012 decision 4). + * + * Driven from the keyboard first, because a menu reachable only by a right + * click is one the accessibility gate is right to fail: Shift+F10 on the + * focused row, arrows to Properties, Enter. The panel shows where the file + * sits in its library — the one field only `GET /tracks/{id}` sends — and a + * corrected tag beside what the file said. Escape gives focus back to the row + * the menu was opened from, through the menu that no longer exists. + */ +test("opens a track's properties from its context menu", async ({ page }) => { + await page.route("**/api/v2/songs?*", (route) => + route.fulfill({ json: [correctable.song] }), + ); + await page.route("**/api/v2/tracks/song-9", (route) => + route.fulfill({ + json: { + ...correctable.song, + relative_path: "Björk/Post/09 Army Of Me.flac", + full_hash: "a".repeat(64), + created_at: Date.UTC(2026, 8, 15, 12, 0), + album_artist: "Björk", + comment: "Remastered\nBonus edition", + musicbrainz_id: null, + isrc: ["GBAAA9500009"], + bpm: 92, + replay_gain_track_gain: -6.5, + replay_gain_track_peak: 0.98, + replay_gain_album_gain: null, + replay_gain_album_peak: null, + }, + }), + ); + await page.goto("/songs"); + const row = page.locator(".songs tbody tr").first(); + const title = row.locator("button.song-title"); + await title.focus(); + + await page.keyboard.press("Shift+F10"); + const menu = page.getByRole("menu", { name: "Actions for Army Of Me" }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem")).toHaveText([ + "Play", + "Add to queue", + "Properties", + "Edit tags", + ]); + await expect(menu.getByRole("menuitem", { name: "Play" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect(menu.getByRole("menuitem", { name: "Properties" })).toBeFocused(); + await page.keyboard.press("Enter"); + + const panel = page.getByRole("dialog", { name: "Army Of Me" }); + await expect(panel).toBeVisible(); + await expect(menu).toHaveCount(0); + await expect(panel).toContainText("Björk/Post/09 Army Of Me.flac"); + await expect(panel).toContainText("a".repeat(64)); + await expect(panel).toContainText("957 kbps"); + await expect(panel).toContainText("44.1 kHz"); + await expect(panel).toContainText("-6.5 dB, peak 0.98"); + // The comment is a correction; the file carried none. + await expect(panel).toContainText("Corrected — the file says —"); + await expectNoWcagViolations(page); + + // A click in the panel's own padding lands on the dialog element, as a + // click on the backdrop does; only the second may close it. + await panel.click({ position: { x: 6, y: 6 } }); + await expect(panel).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(panel).toHaveCount(0); + await expect(title).toBeFocused(); + + // The backdrop, outside the panel's box, does close it. + await page.keyboard.press("Shift+F10"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await expect(panel).toBeVisible(); + await page.mouse.click(2, 2); + await expect(panel).toHaveCount(0); + + // The pointer reaches the same menu, and a click away closes it. + await row.click({ button: "right" }); + await expect(menu).toBeVisible(); + await page.mouse.click(5, 5); + await expect(menu).toHaveCount(0); +}); + test("gives random playback one clear primary action", async ({ page }) => { await page.route("**/api/v2/songs/random?*", (route) => route.fulfill({ json: [track] }), diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 1b0b5abe..82220287 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -138,6 +138,7 @@ export type { TrackCorrectionPatch, TrackOverrides, TrackOverrideValues, + TrackProperties, TrackSourceTags, } from "./tracks"; export { @@ -145,6 +146,7 @@ export { getLyrics, getTrackCredits, getTrackOverrides, + getTrackProperties, } from "./tracks"; export type { CommittedUpload, diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index 471e7e92..d75bc585 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -11,6 +11,7 @@ import { getTrack, getTrackCredits, getTrackOverrides, + getTrackProperties, listAlbums, listApiTokens, listArtistAlbums, @@ -185,6 +186,26 @@ export const bookmarksQuery = () => export const radioStationsQuery = () => queryOptions({ queryKey: ["radio-stations"], queryFn: listRadioStations }); +/** + * One track as its properties panel shows it, with the corrections behind + * its tags so each corrected value can say that it is one. + * + * Not `trackEditQuery`, though it reads the same two routes: that one is the + * editor's and is invalidated by its saves; sharing a key would tie the two + * screens' lifetimes together for no saving worth having. + */ +export const trackPropertiesQuery = (trackId: string) => + queryOptions({ + queryKey: ["track-properties", trackId], + queryFn: async () => { + const [track, tracked] = await Promise.all([ + getTrackProperties(trackId), + getTrackOverrides(trackId), + ]); + return { track, tracked }; + }, + }); + export const lyricsQuery = (trackId: string) => queryOptions({ queryKey: ["lyrics", trackId], diff --git a/webapp/src/api/tracks.ts b/webapp/src/api/tracks.ts index ed5a56f7..a1a4b88c 100644 --- a/webapp/src/api/tracks.ts +++ b/webapp/src/api/tracks.ts @@ -15,6 +15,34 @@ export type SongCredits = Song & { export const getTrackCredits = (id: string) => call(`/api/v2/tracks/${id}`); +/** + * Everything the properties panel shows for one track: what `GET + * /api/v2/tracks/{id}` answers, which is the `SongItem` every listing carries + * plus where the file sits in its library. + * + * Each field below is one the panel prints; the rest of the answer is not + * declared, for the reason `Song` gives — a declaration is a claim the mocks + * then confirm, so it should only be made about what is read. + */ +export type TrackProperties = SongCredits & { + /** Relative to the library root, `/`-separated. Only this route sends it. */ + relative_path: string; + full_hash: string; + created_at: number; + album_artist: string | null; + comment: string | null; + musicbrainz_id: string | null; + isrc: string[]; + bpm: number | null; + replay_gain_track_gain: number | null; + replay_gain_track_peak: number | null; + replay_gain_album_gain: number | null; + replay_gain_album_peak: number | null; +}; + +export const getTrackProperties = (id: string) => + call(`/api/v2/tracks/${id}`); + /** * What the file's tags say, as the last scan read them. Artists and genres are * absent on purpose: a correction to either replaces the rows the scan wrote, diff --git a/webapp/src/components/icons.tsx b/webapp/src/components/icons.tsx index bb364601..e5681a88 100644 --- a/webapp/src/components/icons.tsx +++ b/webapp/src/components/icons.tsx @@ -28,9 +28,18 @@ export type IconName = | "more" | "close" | "upload" - | "radio"; + | "radio" + | "info"; const paths: Record = { + // A circled i: what a track is, read and not changed. + info: ( + <> + + + + + ), // An arrow into a tray: a file going into the library. upload: ( <> diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index 99737535..a605259d 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -8,6 +8,7 @@ import { useI18n } from "../state/i18n"; import { mayCorrectTracks, useLibraryScope } from "../state/library-scope"; import { Artwork } from "./artwork"; import { Icon } from "./icons"; +import { useTrackMenu } from "./track-menu"; const RATING_STARS = [1, 2, 3, 4, 5]; @@ -96,6 +97,7 @@ export function SongTable({ const [stars, setStars] = useState>({}); const [ratings, setRatings] = useState>({}); const { libraries } = useLibraryScope(); + const { rowProps, menu } = useTrackMenu(); // Offered only where the server would accept the correction. It refuses // anyway; this keeps the table from offering a refusal on every row. const mayCorrect = (song: Song) => @@ -161,6 +163,7 @@ export function SongTable({ {numbering === "track" @@ -232,6 +235,7 @@ export function SongTable({ })} + {menu} ); } diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/track-menu.tsx new file mode 100644 index 00000000..ab963c24 --- /dev/null +++ b/webapp/src/components/track-menu.tsx @@ -0,0 +1,354 @@ +import { useNavigate } from "@tanstack/react-router"; +import { + type KeyboardEvent, + type MouseEvent, + type TouchEvent, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +import type { Song } from "../api"; +import { usePlayer } from "../player/player"; +import { useI18n } from "../state/i18n"; +import { mayCorrectTracks, useLibraryScope } from "../state/library-scope"; +import { Icon } from "./icons"; +import { TrackProperties } from "./track-properties"; + +/** Where a menu was asked for, and for which row. */ +type Opened = { + songs: Song[]; + position: number; + x: number; + y: number; + /** What had focus, so closing gives it back (RFC-012 decision 4). */ + returnTo: HTMLElement | null; +}; + +/** How long a finger rests on a row before it means "menu". */ +const LONG_PRESS_MS = 500; +/** How far it may drift and still be a press rather than a scroll. */ +const LONG_PRESS_SLOP_PX = 10; + +/** + * The right click on a track, and what it opens. + * + * `rowProps` goes on each row. A menu reachable only by a right click would be + * one the accessibility gate is right to fail, so the same menu answers the + * context-menu key and Shift+F10 on anything focused in the row, and a long + * press on touch, where iOS fires no `contextmenu` at all. + * + * It holds only what exists: play, add to the queue, the properties panel, and + * the tag editor for those who may correct. RFC-012 adds download and filing + * in later steps. + */ +export function useTrackMenu() { + const [opened, setOpened] = useState(null); + // The row's focus travels with the panel: the `` hands focus back + // to what was focused when it opened, which is the menu entry, gone by then. + const [properties, setProperties] = useState<{ + song: Song; + returnTo: HTMLElement | null; + } | null>(null); + const press = useRef<{ + timer: number; + x: number; + y: number; + } | null>(null); + + function open(songs: Song[], position: number, x: number, y: number) { + setOpened({ + songs, + position, + x, + y, + returnTo: + document.activeElement instanceof HTMLElement + ? document.activeElement + : null, + }); + } + + function cancelPress() { + if (press.current) window.clearTimeout(press.current.timer); + press.current = null; + } + + const rowProps = (songs: Song[], position: number) => ({ + onContextMenu: (event: MouseEvent) => { + event.preventDefault(); + cancelPress(); + // A keyboard-raised `contextmenu` carries no pointer position; the menu + // then opens beside the row instead of in the window's corner. + if (event.clientX === 0 && event.clientY === 0) { + const rect = event.currentTarget.getBoundingClientRect(); + open(songs, position, rect.left + 24, rect.bottom); + } else { + open(songs, position, event.clientX, event.clientY); + } + }, + onKeyDown: (event: KeyboardEvent) => { + if ( + event.key === "ContextMenu" || + (event.key === "F10" && event.shiftKey) + ) { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + open(songs, position, rect.left + 24, rect.bottom); + } + }, + onTouchStart: (event: TouchEvent) => { + const touch = event.touches[0]; + if (!touch || event.touches.length > 1) return; + cancelPress(); + const x = touch.clientX; + const y = touch.clientY; + press.current = { + x, + y, + timer: window.setTimeout(() => { + press.current = null; + open(songs, position, x, y); + }, LONG_PRESS_MS), + }; + }, + onTouchMove: (event: TouchEvent) => { + const touch = event.touches[0]; + const start = press.current; + if ( + start && + touch && + Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > + LONG_PRESS_SLOP_PX + ) { + cancelPress(); + } + }, + onTouchEnd: cancelPress, + onTouchCancel: cancelPress, + }); + + function close() { + const returnTo = opened?.returnTo; + setOpened(null); + returnTo?.focus(); + } + + const menu = ( + <> + {opened ? ( + { + setProperties({ song, returnTo: opened.returnTo }); + setOpened(null); + }} + /> + ) : null} + {properties ? ( + { + const returnTo = properties.returnTo; + setProperties(null); + returnTo?.focus(); + }} + /> + ) : null} + + ); + + return { rowProps, menu }; +} + +function TrackMenu({ + opened, + onClose, + onProperties, +}: { + opened: Opened; + onClose: () => void; + onProperties: (song: Song) => void; +}) { + const { t } = useI18n(); + const player = usePlayer(); + const navigate = useNavigate(); + const { libraries } = useLibraryScope(); + const list = useRef(null); + const [place, setPlace] = useState({ left: opened.x, top: opened.y }); + const song = opened.songs[opened.position]; + const mayCorrect = mayCorrectTracks( + libraries.find((library) => library.id === song.library_id), + ); + + // Kept inside the window: a menu opened near the right or bottom edge + // would otherwise hang off it, half unreadable. + useLayoutEffect(() => { + const node = list.current; + if (!node) return; + const rect = node.getBoundingClientRect(); + const margin = 8; + setPlace({ + left: Math.max( + margin, + Math.min(opened.x, window.innerWidth - rect.width - margin), + ), + top: Math.max( + margin, + Math.min(opened.y, window.innerHeight - rect.height - margin), + ), + }); + node.querySelector('[role="menuitem"]')?.focus(); + }, [opened.x, opened.y]); + + // Anything outside the menu closes it: a click, the wheel or a finger + // moving the page, the window losing focus. + // + // **Not the `scroll` event.** Scrolls nobody asked for happen while a menu + // is open — focusing the row that raised it scrolls it into view on the next + // frame, and a page still rising into place moves the row under it — and + // closing on those shut the menu the instant it appeared. What closes it is + // the visitor setting out to scroll, outside the menu. + useEffect(() => { + const away = (event: Event) => { + if (!list.current?.contains(event.target as Node)) onClose(); + }; + const leave = () => onClose(); + // A long press opens the menu with the finger still down, and that finger + // drifts as it lifts: its moves are the gesture that opened the menu, not + // one leaving it. Only a touch that starts after the menu is open counts. + let armed = false; + const arm = () => { + armed = true; + }; + const dragged = (event: Event) => { + if (armed) away(event); + }; + document.addEventListener("pointerdown", away, true); + document.addEventListener("wheel", away, { capture: true, passive: true }); + document.addEventListener("touchstart", arm, { + capture: true, + passive: true, + }); + document.addEventListener("touchmove", dragged, { + capture: true, + passive: true, + }); + window.addEventListener("blur", leave); + window.addEventListener("resize", leave); + return () => { + document.removeEventListener("pointerdown", away, true); + document.removeEventListener("wheel", away, true); + document.removeEventListener("touchstart", arm, true); + document.removeEventListener("touchmove", dragged, true); + window.removeEventListener("blur", leave); + window.removeEventListener("resize", leave); + }; + }, [onClose]); + + const items: Array<{ + key: string; + icon: Parameters[0]["name"]; + label: string; + run: () => void; + }> = [ + { + key: "play", + icon: "play", + label: t("trackMenu.play"), + run: () => player.play(opened.songs, opened.position), + }, + { + key: "queue", + icon: "queue", + label: t("trackMenu.queue"), + run: () => player.enqueue([song]), + }, + { + key: "properties", + icon: "info", + label: t("trackMenu.properties"), + run: () => onProperties(song), + }, + ...(mayCorrect + ? [ + { + key: "edit", + icon: "edit" as const, + label: t("trackMenu.edit"), + run: () => + void navigate({ + to: "/tracks/$trackId/edit", + params: { trackId: song.id }, + }), + }, + ] + : []), + ]; + + // The keys a menu is expected to answer, per the ARIA menu pattern. Tab + // leaves, and leaving closes: a menu is not a place focus can stay. + function onKeyDown(event: KeyboardEvent) { + const entries = Array.from( + list.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + const at = entries.indexOf(document.activeElement as HTMLElement); + const go = (index: number) => { + event.preventDefault(); + entries[(index + entries.length) % entries.length]?.focus(); + }; + switch (event.key) { + case "ArrowDown": + go(at + 1); + break; + case "ArrowUp": + go(at - 1); + break; + case "Home": + go(0); + break; + case "End": + go(entries.length - 1); + break; + case "Escape": + event.preventDefault(); + onClose(); + break; + case "Tab": + onClose(); + break; + } + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/webapp/src/components/track-properties.tsx b/webapp/src/components/track-properties.tsx new file mode 100644 index 00000000..74719d50 --- /dev/null +++ b/webapp/src/components/track-properties.tsx @@ -0,0 +1,302 @@ +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useEffect, useId, useRef } from "react"; + +import { formatDuration, type Song } from "../api"; +import { lyricsQuery, trackPropertiesQuery } from "../api/queries"; +import { useI18n } from "../state/i18n"; +import { Artwork } from "./artwork"; +import { Icon } from "./icons"; + +/** One row of a list, left out entirely when it has nothing to say. */ +type Row = { label: string; value: ReactNode; corrected?: string | null }; + +/** + * A track's properties, read-only (RFC-012 decision 4). + * + * A native `` opened with `showModal`, for the reasons the "more" sheet + * gives: the focus trap, Escape, and the page behind made inert all come from + * the platform, and the accessibility gate measures them. + * + * Built from what the server already knows. Nothing is computed here that the + * catalogue did not measure, and a field the response does not carry is left + * out rather than printed as zero, which would be a measurement. + */ +export function TrackProperties({ + song, + onClose, +}: { + song: Song; + onClose: () => void; +}) { + const { t, locale } = useI18n(); + const dialog = useRef(null); + const titleId = useId(); + const { data, error } = useQuery(trackPropertiesQuery(song.id)); + const { data: lyrics } = useQuery(lyricsQuery(song.id)); + + useEffect(() => { + const node = dialog.current; + if (node && !node.open) node.showModal(); + }, []); + + const track = data?.track; + const tracked = data?.tracked; + const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); + // A peak is a sample amplitude: 0.98 and 1 are not the same claim. + const amplitude = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 }); + const decibels = new Intl.NumberFormat(locale, { + maximumFractionDigits: 2, + signDisplay: "exceptZero", + }); + const known = (value: number | null | undefined) => + typeof value === "number" ? value : null; + // Where a tag was corrected, the file's own value, so the panel can say both. + const fileSaid = ( + field: keyof NonNullable["overrides"], + source: string | number | null | undefined, + ) => + tracked && tracked.overrides[field] !== null + ? t("properties.fileSaid", { value: source ?? "—" }) + : null; + + const sizeMb = song.size / (1024 * 1024); + const bitrate = known(song.bitrate); + const rate = known(song.sample_rate); + const depth = known(song.bit_depth); + const channels = known(song.channels); + + const file: Row[] = [ + { + label: t("properties.path"), + value: track ? {track.relative_path} : null, + }, + { + label: t("properties.format"), + value: [song.codec, song.suffix.toUpperCase()] + .filter(Boolean) + .filter((value, index, all) => all.indexOf(value) === index) + .join(" · "), + }, + { + label: t("properties.bitrate"), + value: bitrate === null ? null : t("properties.kbps", { value: bitrate }), + }, + { + label: t("properties.sampleRate"), + value: + rate === null + ? null + : t("properties.khz", { value: number.format(rate / 1000) }), + }, + { + label: t("properties.bitDepth"), + value: depth === null ? null : t("properties.bits", { value: depth }), + }, + { + label: t("properties.channels"), + value: channels === null ? null : String(channels), + }, + { + label: t("properties.size"), + value: t("properties.mb", { value: number.format(sizeMb) }), + }, + { + label: t("properties.duration"), + value: formatDuration(song.duration_ms), + }, + { + label: t("properties.added"), + value: track + ? new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(track.created_at) + : null, + }, + { + label: t("properties.hash"), + value: track ? {track.full_hash} : null, + }, + ]; + + const tags: Row[] = [ + { + label: t("properties.title"), + value: track?.title ?? song.title, + corrected: fileSaid("title", tracked?.source.title), + }, + { + label: t("properties.artists"), + value: + track?.artists && track.artists.length > 0 + ? track.artists.map((artist) => artist.name).join(", ") + : song.artist, + corrected: + tracked && tracked.overrides.artists !== null + ? t("properties.corrected") + : null, + }, + { label: t("properties.album"), value: song.album }, + { label: t("properties.albumArtist"), value: track?.album_artist }, + { + label: t("properties.year"), + value: track?.year ?? null, + corrected: fileSaid("year", tracked?.source.year), + }, + { + label: t("properties.trackNumber"), + value: song.track, + corrected: fileSaid("track_number", tracked?.source.track_number), + }, + { + label: t("properties.discNumber"), + value: song.disc, + corrected: fileSaid("disc_number", tracked?.source.disc_number), + }, + { + label: t("properties.genres"), + value: track?.genres?.length ? track.genres.join(", ") : null, + corrected: + tracked && tracked.overrides.genres !== null + ? t("properties.corrected") + : null, + }, + { + label: t("properties.comment"), + value: track?.comment, + corrected: fileSaid("comment", tracked?.source.comment), + }, + { + label: t("properties.isrc"), + value: track?.isrc?.length ? track.isrc.join(", ") : null, + }, + { + label: t("properties.musicbrainz"), + value: track?.musicbrainz_id ? {track.musicbrainz_id} : null, + corrected: fileSaid( + "musicbrainz_recording_id", + tracked?.source.musicbrainz_recording_id, + ), + }, + ]; + + const gain = (value: number | null | undefined, peak?: number | null) => { + const db = known(value); + if (db === null) return null; + const ceiling = known(peak); + return ceiling === null + ? t("properties.db", { value: decibels.format(db) }) + : t("properties.dbPeak", { + value: decibels.format(db), + peak: amplitude.format(ceiling), + }); + }; + const sound: Row[] = [ + { + label: t("properties.trackGain"), + value: gain(track?.replay_gain_track_gain, track?.replay_gain_track_peak), + }, + { + label: t("properties.albumGain"), + value: gain(track?.replay_gain_album_gain, track?.replay_gain_album_peak), + }, + { label: t("properties.bpm"), value: known(track?.bpm) }, + ]; + + const sheets = lyrics?.structuredLyrics ?? []; + const words: Row[] = sheets.length + ? sheets.map((sheet, index) => ({ + label: t("properties.lyricsSheet", { number: index + 1 }), + value: t(sheet.synced ? "properties.synced" : "properties.plain", { + lang: sheet.lang, + }), + })) + : [ + { + label: t("properties.lyricsSheets"), + value: t("properties.noLyrics"), + }, + ]; + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes a natively + { + // The backdrop is the dialog element too, so its target alone does + // not tell them apart: a click in the panel's padding or between two + // rows also lands on the dialog itself. Outside its box is the + // backdrop; inside it is the panel. + const node = dialog.current; + if (!node || event.target !== node) return; + const box = node.getBoundingClientRect(); + const inside = + event.clientX >= box.left && + event.clientX <= box.right && + event.clientY >= box.top && + event.clientY <= box.bottom; + if (!inside) node.close(); + }} + > +
+ +
+ {t("properties.kicker")} +

{song.title}

+

+ {[song.artist, song.album].filter(Boolean).join(" · ")} +

+
+ +
+ {error ? ( +

+ {t("properties.loadError")} +

+ ) : null} +
+
+
+
+
+ ); +} + +function Section({ title, rows }: { title: string; rows: Row[] }) { + const shown = rows.filter( + (row) => row.value !== null && row.value !== undefined && row.value !== "", + ); + if (shown.length === 0) return null; + return ( +
+

{title}

+
+ {shown.map((row) => ( +
+
{row.label}
+
+ {row.value} + {row.corrected ? ( + {row.corrected} + ) : null} +
+
+ ))} +
+
+ ); +} diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index 64534508..8bf8899c 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -113,6 +113,55 @@ const en = { "common.cancel": "Cancel", "correction.edit": "Correct tags", "correction.editTrack": "Correct tags: {title}", + "trackMenu.label": "Actions for {title}", + "trackMenu.play": "Play", + "trackMenu.queue": "Add to queue", + "trackMenu.properties": "Properties", + "trackMenu.edit": "Edit tags", + "properties.kicker": "Properties", + "properties.close": "Close properties", + "properties.loadError": "Some properties could not be loaded.", + "properties.file": "File", + "properties.tags": "Tags", + "properties.sound": "Loudness and tempo", + "properties.lyrics": "Lyrics", + "properties.path": "Path in library", + "properties.format": "Format", + "properties.bitrate": "Bit rate", + "properties.sampleRate": "Sample rate", + "properties.bitDepth": "Bit depth", + "properties.channels": "Channels", + "properties.size": "Size", + "properties.duration": "Duration", + "properties.added": "Added", + "properties.hash": "Content hash", + "properties.title": "Title", + "properties.artists": "Artists", + "properties.album": "Album", + "properties.albumArtist": "Album artist", + "properties.year": "Year", + "properties.trackNumber": "Track", + "properties.discNumber": "Disc", + "properties.genres": "Genres", + "properties.comment": "Comment", + "properties.isrc": "ISRC", + "properties.musicbrainz": "MusicBrainz recording", + "properties.trackGain": "ReplayGain (track)", + "properties.albumGain": "ReplayGain (album)", + "properties.bpm": "BPM", + "properties.lyricsSheet": "Sheet {number}", + "properties.synced": "Synced, {lang}", + "properties.plain": "Plain text, {lang}", + "properties.noLyrics": "None", + "properties.lyricsSheets": "Sheets", + "properties.kbps": "{value} kbps", + "properties.khz": "{value} kHz", + "properties.bits": "{value} bit", + "properties.mb": "{value} MB", + "properties.db": "{value} dB", + "properties.dbPeak": "{value} dB, peak {peak}", + "properties.corrected": "Corrected", + "properties.fileSaid": "Corrected — the file says {value}", "correction.detail": "The file is never rewritten. A correction is stored beside it, survives a rescan, and every member of the library sees it.", "correction.fieldTitle": "Title", @@ -672,6 +721,55 @@ const fr: Record = { "common.cancel": "Annuler", "correction.edit": "Corriger les tags", "correction.editTrack": "Corriger les tags : {title}", + "trackMenu.label": "Actions pour {title}", + "trackMenu.play": "Lire", + "trackMenu.queue": "Ajouter à la file", + "trackMenu.properties": "Propriétés", + "trackMenu.edit": "Corriger les tags", + "properties.kicker": "Propriétés", + "properties.close": "Fermer les propriétés", + "properties.loadError": "Certaines propriétés n’ont pas pu être chargées.", + "properties.file": "Fichier", + "properties.tags": "Tags", + "properties.sound": "Volume et tempo", + "properties.lyrics": "Paroles", + "properties.path": "Chemin dans la bibliothèque", + "properties.format": "Format", + "properties.bitrate": "Débit", + "properties.sampleRate": "Fréquence d’échantillonnage", + "properties.bitDepth": "Profondeur", + "properties.channels": "Canaux", + "properties.size": "Taille", + "properties.duration": "Durée", + "properties.added": "Ajouté le", + "properties.hash": "Empreinte du contenu", + "properties.title": "Titre", + "properties.artists": "Artistes", + "properties.album": "Album", + "properties.albumArtist": "Artiste de l’album", + "properties.year": "Année", + "properties.trackNumber": "Piste", + "properties.discNumber": "Disque", + "properties.genres": "Genres", + "properties.comment": "Commentaire", + "properties.isrc": "ISRC", + "properties.musicbrainz": "Enregistrement MusicBrainz", + "properties.trackGain": "ReplayGain (piste)", + "properties.albumGain": "ReplayGain (album)", + "properties.bpm": "BPM", + "properties.lyricsSheet": "Feuille {number}", + "properties.synced": "Synchronisées, {lang}", + "properties.plain": "Texte brut, {lang}", + "properties.noLyrics": "Aucune", + "properties.lyricsSheets": "Feuilles", + "properties.kbps": "{value} kbit/s", + "properties.khz": "{value} kHz", + "properties.bits": "{value} bits", + "properties.mb": "{value} Mo", + "properties.db": "{value} dB", + "properties.dbPeak": "{value} dB, crête {peak}", + "properties.corrected": "Corrigé", + "properties.fileSaid": "Corrigé — le fichier indique {value}", "correction.detail": "Le fichier n’est jamais réécrit. La correction est rangée à côté, survit à un rescan, et tous les membres de la bibliothèque la voient.", "correction.fieldTitle": "Titre", diff --git a/webapp/src/styles/fluid.css b/webapp/src/styles/fluid.css index 481333f8..da471fdc 100644 --- a/webapp/src/styles/fluid.css +++ b/webapp/src/styles/fluid.css @@ -1266,3 +1266,65 @@ transform: none; } } + +/* ── The track menu and its properties ──────────────────────── */ + +/* A menu is a popover: `surface-2`, the card radius, no border, an inset + top highlight and a deep soft shadow; rows with their own radius. */ +:where(:root.dark) .track-menu { + border: 0; + border-radius: var(--fe-radius-card); + background: var(--fe-surface-2); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 6%), + 0 1.5rem 3rem rgb(0 0 0 / 55%); +} + +:where(:root.dark) .track-menu-item { + border-radius: 0.5rem; + background: transparent; + color: var(--fe-ink-2); +} + +:where(:root.dark) .track-menu-item:hover, +:where(:root.dark) .track-menu-item:focus-visible { + background: var(--fe-tone-2); + color: var(--fe-ink); +} + +:where(:root.dark) .track-properties[open] { + border: 0; + border-radius: var(--fe-radius-hero); + background: var(--fe-surface); + box-shadow: + inset 0 0 0 1px var(--fe-hairline), + 0 2rem 5rem rgb(0 0 0 / 55%); +} + +:where(:root.dark) .track-properties h3 { + color: var(--fe-ink-3); + font-size: var(--fe-label); + font-weight: 540; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +:where(:root.dark) .property-row { + border-bottom-color: rgb(255 255 255 / 5%); +} + +:where(:root.dark) .property-list dt { + color: var(--fe-ink-3); +} + +:where(:root.dark) .property-list dd { + color: var(--fe-ink); +} + +:where(:root.dark) .properties-cover { + border-radius: var(--fe-radius-tile); +} + +:where(:root.dark) .properties-close { + background: var(--fe-tone-2); +} diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index 649e63ba..ae728897 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -2665,3 +2665,135 @@ button.danger { background: var(--accent-soft); color: var(--accent); } + +/* The right click on a track (RFC-012 decision 4). Fixed to the viewport at + the pointer, and moved back inside it by the component when it would hang + off an edge. */ +.track-menu { + position: fixed; + z-index: 40; + display: grid; + min-width: 13rem; + padding: var(--space-1); + border: 1px solid var(--line-strong); + border-radius: 0.75rem; + background: var(--panel-strong); + box-shadow: var(--shadow); +} + +.track-menu-item { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 2.25rem; + padding: var(--space-2) var(--space-3); + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--text); + font-size: var(--text-sm); + text-align: left; +} + +.track-menu-item:hover, +.track-menu-item:focus-visible { + background: var(--accent-soft); + outline: none; +} + +/* A track's properties: a sheet of facts, read-only. */ +.track-properties[open] { + display: grid; + gap: var(--space-6); + width: min(42rem, calc(100vw - 2rem)); + max-height: min(48rem, calc(100dvh - 2rem)); + padding: var(--space-6); + overflow-y: auto; + border: 1px solid var(--line-strong); + border-radius: 1rem; + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); +} + +.track-properties::backdrop { + background: color-mix(in srgb, black 55%, transparent); +} + +.track-properties header { + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-4); +} + +.properties-cover { + width: 4.5rem; +} + +.track-properties h2 { + margin: 0; + font-size: var(--text-xl); + overflow-wrap: anywhere; +} + +.track-properties header p { + margin: var(--space-1) 0 0; +} + +.properties-close { + display: grid; + width: 2.25rem; + min-height: 2.25rem; + padding: 0; + place-items: center; +} + +.track-properties h3 { + margin: 0 0 var(--space-3); + font-size: var(--text-base); +} + +.property-list { + display: grid; + margin: 0; +} + +.property-row { + display: grid; + grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr); + gap: var(--space-4); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--line); +} + +.property-list dt { + color: var(--muted); + font-size: var(--text-sm); +} + +.property-list dd { + display: grid; + gap: var(--space-1); + margin: 0; + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.property-list code { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: var(--text-xs); +} + +.property-corrected { + color: var(--accent); + font-size: var(--text-xs); +} + +@media (width <= 35rem) { + .property-row { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-1); + } +} From 19f3894a006ced5cdd442f80c638816255e2afe7 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 06:03:28 +0200 Subject: [PATCH 2/5] fix(webapp): keep a long press and a missing correction from misleading - After a long press opens the track menu, the click the browser synthesises as the finger lifts is swallowed; it started playback under the menu. - A failure to read a track's corrections costs only the "corrected" marks, not the properties panel. - The lyrics section waits for an answer before saying "None". Signed-off-by: InstaZDLL --- webapp/src/api/queries.ts | 4 +++- webapp/src/components/track-menu.tsx | 12 ++++++++++++ webapp/src/components/track-properties.tsx | 20 +++++++++++++------- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index d75bc585..c2aaddec 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -198,9 +198,11 @@ export const trackPropertiesQuery = (trackId: string) => queryOptions({ queryKey: ["track-properties", trackId], queryFn: async () => { + // The corrections only mark which tags were corrected. A failure to + // read them costs those marks, not the panel. const [track, tracked] = await Promise.all([ getTrackProperties(trackId), - getTrackOverrides(trackId), + getTrackOverrides(trackId).catch(() => null), ]); return { track, tracked }; }, diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/track-menu.tsx index ab963c24..a760ba30 100644 --- a/webapp/src/components/track-menu.tsx +++ b/webapp/src/components/track-menu.tsx @@ -51,6 +51,10 @@ export function useTrackMenu() { song: Song; returnTo: HTMLElement | null; } | null>(null); + // A long press that opened the menu is followed, as the finger lifts, by the + // click the browser synthesises for the tap it thinks it saw; on a row whose + // title plays the track, that click would start playback under the menu. + const swallowClick = useRef(false); const press = useRef<{ timer: number; x: number; @@ -98,7 +102,14 @@ export function useTrackMenu() { open(songs, position, rect.left + 24, rect.bottom); } }, + onClickCapture: (event: MouseEvent) => { + if (!swallowClick.current) return; + swallowClick.current = false; + event.preventDefault(); + event.stopPropagation(); + }, onTouchStart: (event: TouchEvent) => { + swallowClick.current = false; const touch = event.touches[0]; if (!touch || event.touches.length > 1) return; cancelPress(); @@ -109,6 +120,7 @@ export function useTrackMenu() { y, timer: window.setTimeout(() => { press.current = null; + swallowClick.current = true; open(songs, position, x, y); }, LONG_PRESS_MS), }; diff --git a/webapp/src/components/track-properties.tsx b/webapp/src/components/track-properties.tsx index 74719d50..a437f146 100644 --- a/webapp/src/components/track-properties.tsx +++ b/webapp/src/components/track-properties.tsx @@ -32,7 +32,9 @@ export function TrackProperties({ const dialog = useRef(null); const titleId = useId(); const { data, error } = useQuery(trackPropertiesQuery(song.id)); - const { data: lyrics } = useQuery(lyricsQuery(song.id)); + const { data: lyrics, isSuccess: lyricsKnown } = useQuery( + lyricsQuery(song.id), + ); useEffect(() => { const node = dialog.current; @@ -204,6 +206,8 @@ export function TrackProperties({ ]; const sheets = lyrics?.structuredLyrics ?? []; + // "None" is an answer, so it waits for one: while the lyrics are still + // being asked for, the section says nothing rather than something false. const words: Row[] = sheets.length ? sheets.map((sheet, index) => ({ label: t("properties.lyricsSheet", { number: index + 1 }), @@ -211,12 +215,14 @@ export function TrackProperties({ lang: sheet.lang, }), })) - : [ - { - label: t("properties.lyricsSheets"), - value: t("properties.noLyrics"), - }, - ]; + : lyricsKnown + ? [ + { + label: t("properties.lyricsSheets"), + value: t("properties.noLyrics"), + }, + ] + : []; return ( // biome-ignore lint/a11y/useKeyWithClickEvents: Escape closes a natively From 0a914753cda9c4894d554547efdae6f1f95131e2 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 06:08:14 +0200 Subject: [PATCH 3/5] fix(webapp): install the track menu's close listeners once The parent hands a new onClose on every render, so the effect re-installed its listeners each time and reset the long-press guard mid-gesture. The latest onClose is read through a ref and the listeners are installed once per menu. Signed-off-by: InstaZDLL --- webapp/src/components/track-menu.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/track-menu.tsx index a760ba30..e1ad16e2 100644 --- a/webapp/src/components/track-menu.tsx +++ b/webapp/src/components/track-menu.tsx @@ -223,11 +223,17 @@ function TrackMenu({ // frame, and a page still rising into place moves the row under it — and // closing on those shut the menu the instant it appeared. What closes it is // the visitor setting out to scroll, outside the menu. + // + // Installed once per menu, with the latest `onClose` read through a ref: the + // parent hands a new one on every render, and re-installing on each would + // also reset `armed` below in the middle of a gesture. + const closeRef = useRef(onClose); + closeRef.current = onClose; useEffect(() => { const away = (event: Event) => { - if (!list.current?.contains(event.target as Node)) onClose(); + if (!list.current?.contains(event.target as Node)) closeRef.current(); }; - const leave = () => onClose(); + const leave = () => closeRef.current(); // A long press opens the menu with the finger still down, and that finger // drifts as it lifts: its moves are the gesture that opened the menu, not // one leaving it. Only a touch that starts after the menu is open counts. @@ -258,7 +264,7 @@ function TrackMenu({ window.removeEventListener("blur", leave); window.removeEventListener("resize", leave); }; - }, [onClose]); + }, []); const items: Array<{ key: string; From 5c47f8367be971e83093fca27610578a83aa78e8 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 06:16:54 +0200 Subject: [PATCH 4/5] fix(webapp): swallow the tap after an Android long press too Android opens the menu from a long press through its own contextmenu event, which cancels the timer that would have armed the guard; the lift that follows then played the track. A touch-raised contextmenu arms it as well, and the next touch disarms it. Signed-off-by: InstaZDLL --- webapp/src/components/track-menu.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/track-menu.tsx index e1ad16e2..a40ad0c9 100644 --- a/webapp/src/components/track-menu.tsx +++ b/webapp/src/components/track-menu.tsx @@ -83,6 +83,13 @@ export function useTrackMenu() { onContextMenu: (event: MouseEvent) => { event.preventDefault(); cancelPress(); + // Android opens the menu from a long press itself, through this event, + // before the timer below fires: the lift that follows is the same tap + // to swallow. The next touch disarms it, so it cannot eat a later one. + const native = event.nativeEvent as globalThis.MouseEvent & { + pointerType?: string; + }; + if (native.pointerType === "touch") swallowClick.current = true; // A keyboard-raised `contextmenu` carries no pointer position; the menu // then opens beside the row instead of in the window's corner. if (event.clientX === 0 && event.clientY === 0) { From ab586cd380e4de4d2c78a8cdb6b6cc0a2e735c14 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 06:26:46 +0200 Subject: [PATCH 5/5] fix(webapp): cancel the long-press lift, and show where menu focus is - After a long press opens the track menu, the touchend is cancelled, so the browser never synthesises the tap's click; the click capture stays as a net. - A focused menu entry carries a 2 px accent outline, not only a tint too faint against the light themes' panel. The e2e test checks it, and fails on the previous outline: none. Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 5 +++++ webapp/src/components/track-menu.tsx | 11 ++++++++++- webapp/src/styles/styles.css | 8 +++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 0150debb..fa0589c3 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -1423,6 +1423,11 @@ test("opens a track's properties from its context menu", async ({ page }) => { "Edit tags", ]); await expect(menu.getByRole("menuitem", { name: "Play" })).toBeFocused(); + // Where focus is shows on its own, not only as a faint tint. + await expect(menu.getByRole("menuitem", { name: "Play" })).toHaveCSS( + "outline-style", + "solid", + ); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); await expect(menu.getByRole("menuitem", { name: "Properties" })).toBeFocused(); diff --git a/webapp/src/components/track-menu.tsx b/webapp/src/components/track-menu.tsx index a40ad0c9..42016409 100644 --- a/webapp/src/components/track-menu.tsx +++ b/webapp/src/components/track-menu.tsx @@ -144,7 +144,16 @@ export function useTrackMenu() { cancelPress(); } }, - onTouchEnd: cancelPress, + onTouchEnd: (event: TouchEvent) => { + cancelPress(); + // Cancelling the lift is what stops the browser from synthesising the + // tap's click at all; `onClickCapture` above stays as the net for a + // browser that sends one anyway. + if (swallowClick.current) { + swallowClick.current = false; + event.preventDefault(); + } + }, onTouchCancel: cancelPress, }); diff --git a/webapp/src/styles/styles.css b/webapp/src/styles/styles.css index ae728897..f1d721a4 100644 --- a/webapp/src/styles/styles.css +++ b/webapp/src/styles/styles.css @@ -2698,7 +2698,13 @@ button.danger { .track-menu-item:hover, .track-menu-item:focus-visible { background: var(--accent-soft); - outline: none; +} + +/* The arrow keys move focus through the menu, so where it is must be seen + on its own: a tint alone is too faint against the light themes' panel. */ +.track-menu-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } /* A track's properties: a sheet of facts, read-only. */