From 7906ee13eb298a199079e10e59e2c911e3d4f0e4 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 18:44:33 +0200 Subject: [PATCH 1/2] fix(ui): honour single-click play in every list of tracks The setting was read by the library's song list alone; album, artist, genre, year, liked, history, playlist and queue rows still asked for a double-click. A shared useSingleClickPlay hook and isPlayClick test now drive them all. Refs #932 --- docs/features/library.md | 2 +- docs/features/ui.md | 2 +- src/components/layout/QueuePanel.tsx | 16 ++++++- src/components/player/ImmersiveQueueTab.tsx | 16 ++++++- src/components/views/AlbumDetailView.tsx | 16 ++++++- src/components/views/ArtistDetailView.tsx | 12 ++++- src/components/views/GenreDetailView.tsx | 12 ++++- src/components/views/HistoryView.tsx | 16 ++++++- src/components/views/LibraryView.tsx | 16 +------ src/components/views/LikedView.tsx | 12 ++++- src/components/views/PlaylistView.tsx | 25 +++++++++- src/components/views/SettingsView.tsx | 25 +++------- src/hooks/useSingleClickPlay.ts | 51 +++++++++++++++++++++ 13 files changed, 178 insertions(+), 43 deletions(-) create mode 100644 src/hooks/useSingleClickPlay.ts diff --git a/docs/features/library.md b/docs/features/library.md index b054c1266..de0dfc801 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -154,7 +154,7 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src - **Years tab** — [`years.rs`](../../src-tauri/crates/app/src/commands/years.rs). `list_years` groups the available tracks by the year their tags give, newest first, with the tracks that have none last under "Unknown year"; a year outside 1–9999 (a `0`, a full date pasted into the field) counts as none rather than as a year of its own. Each year carries its track and album counts and the cover of the album holding most of its tracks, the lowest album id breaking a tie so the tile does not change between loads. The tab lays the years out in a section per decade. A tile opens the year page, `get_year_detail`, which lists the tracks through the same query the genre page uses (`detail_tracks_sql` / `detail_track` in `browse.rs`), filtered on the same year expression, and shows the cover the tile did. `album.year` is not used: an album's tracks can carry different years, and the tab lists tracks. - **Bulk list endpoints wire format** — `list_tracks` / `list_playlist_tracks` / `list_liked_tracks` (track-shaped) and `list_albums` / `list_artists` (browse-shaped) all return `{ artwork_base, items: [] }` instead of the full row shape (artists additionally include `metadata_artwork_base` for the Deezer cache). Each slim row carries `artwork_hash` + `artwork_format` + `artwork_has_1x` + `artwork_has_2x` (artists also `picture_hash` + `picture_has_*`) instead of three full path strings; the ~70-char per-profile prefix only appears once in the response. Frontend wrappers ([`expandTrackResponse`](../../src/lib/tauri/track.ts), `expandAlbumRow` / `expandArtistRow` in [`browse.ts`](../../src/lib/tauri/browse.ts)) stitch the absolute paths back together so every UI consumer keeps the full `Track` / `AlbumRow` / `ArtistRow` shape unchanged. Cuts ~30 % off each payload (e.g. ≈ 1.0 MB → ≈ 700 kB on a 1k-track `list_tracks`, ≈ 650 kB → ≈ 250 kB on a 900-artist `list_artists`), proportionally shrinking JSON parse + IPC transfer time. Any future bulk endpoint shipping artworks for hundreds of rows should adopt the same `{ artwork_base, items }` shape. - **Artist page** — its track list comes from `list_tracks` with an `artist_id`, matched **by row id**: every track that credits the artist through `track_artist`, in any position, plus every track of an album whose album artist they are. It used to load the whole library and keep the rows whose display string, split on `", "`, contained the name — so "Earth, Wind & Fire" had no tracks, the multi-artist rule broken on the one page that needed it. [`get_artist_detail`](../../src-tauri/crates/app/src/commands/browse.rs) counts and lists albums the same way, so an artist who is only ever an album artist ("Various Artists", reached from a compilation's page) gets a page instead of "artist not found". -- **Single-click play** — with `ui.single_click_play` on, the first click of a row plays it; the second click of a double-click (`detail > 1`) and the row's `dblclick` stand down, so a double-click out of habit is one play instead of three. Either way, playing a row clears the selection its own click made (#897): with double-click play the first click selects the row, and the blue selection used to outrank the green "now playing" on that very row. The library, album and playlist tables all do this; playing a selection from the action bar is a separate path and keeps it. +- **Single-click play** — with `ui.single_click_play` on, the first click of a row plays it, in every list of tracks: the library's tables, album, artist, genre and year pages, liked tracks, history, playlists, the queue panel and the immersive queue (#932: only the library's song list read it, every other list still asked for a double-click). They all read it through [`useSingleClickPlay`](../../src/hooks/useSingleClickPlay.ts), so a change in Settings reaches the lists already on screen, and decide with `isPlayClick`: a plain click on the row itself, not one with a modifier (those select) nor one on a control inside the row (a like button, an artist link, a drag handle). A list rendered once per row (playlist, queue, history days) reads the setting once and hands it down, rather than once per row; the second click of a double-click (`detail > 1`) and the row's `dblclick` stand down, so a double-click out of habit is one play instead of three. Either way, playing a row clears the selection its own click made (#897): with double-click play the first click selects the row, and the blue selection used to outrank the green "now playing" on that very row. The library, album and playlist tables all do this; playing a selection from the action bar is a separate path and keeps it. - **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly. - **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. **Ctrl+A** (Cmd+A) selects every local row of the list on screen — the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) — ([`useSelectAllShortcut`](../../src/hooks/useSelectAllShortcut.ts)) — and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a **checkbox** ([`RowSelectCheckbox`](../../src/components/common/RowSelectCheckbox.tsx)) on keyboard focus (it is hidden by opacity, so it stays in the tab order) and on every row once a selection has been _asked for_ — a Ctrl or Shift click, or Ctrl+A (`useMultiSelect().checkboxMode`). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what [#859](https://github.com/InstaZDLL/WaveFlow/issues/859) reported. The box no longer shows on hover, in either click mode ([#867](https://github.com/InstaZDLL/WaveFlow/issues/867)): it sits where the number is, so it caught clicks meant for the row, and it put a checkbox under every pass of the mouse. Each box is named after its row for screen readers; its click and Space never reach the row, which would play it or reset the selection. A row clicked with the mouse never shows a focus ring, whatever key is pressed next — Escape, a digit, or a desktop shortcut such as Super+Q under Hyprland ([#866](https://github.com/InstaZDLL/WaveFlow/issues/866)): [`inputModality`](../../src/lib/inputModality.ts) marks focus that arrives from a pointer, and `app.css` keeps a marked element ringless; focus reached from the keyboard keeps its ring. Server rows have no rowid to select and keep their number. - **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second. **What a save sends, and what it changes.** Title, numbers and year always; artist, album and genre only when the field differs from what the track holds. The artist field is filled from `get_track_artists`, the credits joined with `"; "` — the only separator `split_artist_name` splits on — not from the row's display `artist_name`, which is joined with `", "`: echoing that back merged a multi-artist track's credits into one artist named "A, B", in the database and in the file, on any save, a title-only one included. Artists, albums and genres are found by a canonical key that ignores case, punctuation and spacing, so "The Wall" typed over "the wall" lands on the same row; the save then **respells** that row (`respell` in `edit.rs`), since nothing else ever renamed it and the fix showed in the file alone. Only on an explicit edit: the scanner leaves names as they were first seen, or two files spelling an artist differently would rename it back and forth on every scan. The save also recomputes the year of the album the track left and the one it joined (`refresh_album_years`, shared with the folder scan), which the header otherwise kept until the next scan of that folder. **A file changed by another program while the dialog is open is not overwritten.** Since the title, numbers and year always go back, a tag written by another tagger in the meantime would have been silently reverted to the values the dialog showed. The dialog reads the file's stamp (`get_track_file_stamp`: size and modification time, the pair the scanner's fast path trusts, as an opaque string so the nanoseconds survive JSON) each time it loads the track, and hands it back with the save; `update_track_tags` checks it before pausing playback and again just before the write, and refuses with `AppError::FileChangedOnDisk` when the file moved. The save returns the new stamp, so the dialog's own writes — and the rating and cover, which refresh the track — never trip it. The refusal names the cause and offers **Reload from file**, which rescans the track's library folder (`reread_track_folder`, the ordinary scan, so unchanged files cost a stat) and refills the form from the result: reloading from the database alone would show the same stale values with a fresh stamp. A change made _before_ the dialog opened and not yet scanned is outside what the stamp can see; a watched folder catches that one. The batch editor and the tag fetcher send only the fields the user chose, so they carry no stamp. diff --git a/docs/features/ui.md b/docs/features/ui.md index 0708b523a..c573f1787 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -524,7 +524,7 @@ User overrides are stored per-profile in `profile_setting['ui.shortcuts']` as a - **Dark mode** — animated radial transition via the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API). Falls back to an instant swap when unsupported. - **`prefers-reduced-motion`** respected for the radial transition and for animated SVGs. Loading spinners stop too (one `.animate-spin` rule in [`app.css`](../../src/app.css)); that is why a busy button swaps its icon for `Loader2` instead of spinning its own icon — a stopped `Loader2` still reads as loading, a stopped refresh arrow reads as an idle button. -- **Single-click play** — optional Settings toggle; the default is double-click to mirror Apple Music / Finder. +- **Single-click play** — optional Settings toggle, honoured by every list of tracks ([library.md](library.md)); the default is double-click. - **Framer Motion** — `motion/react` provides micro-interactions (sidebar nav reorder, modal open, view fade-in, queue drag). One global [`SkinMotionWrapper`](../../src/components/layout/SkinMotionWrapper.tsx) feeds skin-specific `transition` config to the `MotionConfig` provider so per-skin springs (Pulse uses `cubic-bezier(0.34, 1.56, 0.64, 1)`, Lounge stays tame, etc.) apply automatically without touching call sites. ## High contrast diff --git a/src/components/layout/QueuePanel.tsx b/src/components/layout/QueuePanel.tsx index 6f1e42bde..5d5279a68 100644 --- a/src/components/layout/QueuePanel.tsx +++ b/src/components/layout/QueuePanel.tsx @@ -34,6 +34,10 @@ import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { useVirtualizer } from "@tanstack/react-virtual"; import { usePlayer } from "../../hooks/usePlayer"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { usePlayerTrackContextMenu } from "../../hooks/usePlayerTrackContextMenu"; import { queuePayloadToTrack } from "../../lib/queueTrack"; import { Artwork } from "../common/Artwork"; @@ -484,6 +488,7 @@ function SortableUpNext({ onRowMenuKey, }: SortableUpNextProps) { "use no memo"; + const { value: singleClickPlay } = useSingleClickPlay(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), ); @@ -569,6 +574,7 @@ function SortableUpNext({ onJump(absoluteIndex)} + onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) onJump(absoluteIndex); + }} + onDoubleClick={() => { + if (!singleClickPlay) onJump(absoluteIndex); + }} onContextMenu={(e) => onContextMenu(e, item)} tabIndex={0} role="button" diff --git a/src/components/player/ImmersiveQueueTab.tsx b/src/components/player/ImmersiveQueueTab.tsx index d46566dcd..7f5c3e38c 100644 --- a/src/components/player/ImmersiveQueueTab.tsx +++ b/src/components/player/ImmersiveQueueTab.tsx @@ -1,5 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { ListMusic, Radio } from "lucide-react"; import { Artwork } from "../common/Artwork"; @@ -21,6 +25,7 @@ import { */ export function ImmersiveQueueTab() { const { t } = useTranslation(); + const { value: singleClickPlay } = useSingleClickPlay(); const [snapshot, setSnapshot] = useState(null); // Seq guard so overlapping refetches (rapid Next clicks fire many @@ -131,6 +136,7 @@ export function ImmersiveQueueTab() { handleJump(absoluteIndex)} /> ); @@ -145,16 +151,24 @@ export function ImmersiveQueueTab() { function ImmersiveQueueRow({ item, isCurrent = false, + singleClickPlay = false, onJump, }: { item: QueueTrackPayload; isCurrent?: boolean; + /** Settings → single-click play, read once by the tab. */ + singleClickPlay?: boolean; onJump?: () => void; }) { const interactive = !isCurrent && onJump != null; return (
{ + if (onJump && isPlayClick(e, singleClickPlay)) onJump(); + }} + onDoubleClick={() => { + if (!singleClickPlay) onJump?.(); + }} tabIndex={interactive ? 0 : undefined} role={interactive ? "button" : undefined} onKeyDown={ diff --git a/src/components/views/AlbumDetailView.tsx b/src/components/views/AlbumDetailView.tsx index 45f5bc3b6..bdb845950 100644 --- a/src/components/views/AlbumDetailView.tsx +++ b/src/components/views/AlbumDetailView.tsx @@ -30,6 +30,10 @@ import { SelectionActionBar } from "../common/SelectionActionBar"; import { Lightbox } from "../common/Lightbox"; import { convertFileSrc } from "@tauri-apps/api/core"; import { usePlayer } from "../../hooks/usePlayer"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { useTrackContextMenu } from "../../hooks/useTrackContextMenu"; import { useTrackUpdated } from "../../hooks/useTrackUpdated"; import { useMultiSelect } from "../../hooks/useMultiSelect"; @@ -735,6 +739,7 @@ function AlbumTrackTable({ onToggleSelect, onRowSelect, }: AlbumTrackTableProps) { + const { value: singleClickPlay } = useSingleClickPlay(); const gridCols = "grid-cols-[3rem_1fr_1fr_5rem_2rem]"; const renderTrackRow = (track: AlbumTrack, globalIndex: number) => { @@ -755,9 +760,18 @@ function AlbumTrackTable({ tabIndex={0} role="button" onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) { + onPlayTrack(globalIndex); + return; + } + // The second click of a double-click: the first one already + // played the row (#897). + if (singleClickPlay && e.detail > 1) return; if (playable) onRowSelect(playable, e); }} - onDoubleClick={() => onPlayTrack(globalIndex)} + onDoubleClick={() => { + if (!singleClickPlay) onPlayTrack(globalIndex); + }} onKeyDown={(e) => { // Only play when the row itself is focused — see LibraryView. if (e.target !== e.currentTarget) return; diff --git a/src/components/views/ArtistDetailView.tsx b/src/components/views/ArtistDetailView.tsx index bb2d94d62..280c9933b 100644 --- a/src/components/views/ArtistDetailView.tsx +++ b/src/components/views/ArtistDetailView.tsx @@ -23,6 +23,10 @@ import { HiResBadge } from "../common/HiResBadge"; import { PlayingIndicator } from "../common/PlayingIndicator"; import { Lightbox } from "../common/Lightbox"; import { usePlayer } from "../../hooks/usePlayer"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { ShareImageButton } from "../common/ShareImageButton"; import { renderCollectionCard } from "../../lib/collectionCard"; import { useTrackContextMenu } from "../../hooks/useTrackContextMenu"; @@ -957,6 +961,7 @@ function ArtistTrackTable({ t: (key: string, opts?: Record) => string; }) { "use no memo"; + const { value: singleClickPlay } = useSingleClickPlay(); const gridCols = "grid-cols-[3rem_2.75rem_1fr_1fr_5rem_2rem]"; const { parentRef, virtualizer, scrollMargin } = usePageVirtualizer({ @@ -1002,7 +1007,12 @@ function ArtistTrackTable({ key={`${track.id}-${index}`} tabIndex={0} role="button" - onDoubleClick={() => onPlayTrack(index)} + onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) onPlayTrack(index); + }} + onDoubleClick={() => { + if (!singleClickPlay) onPlayTrack(index); + }} onKeyDown={(e) => { // Only play when the row itself is focused — see LibraryView. if (e.target !== e.currentTarget) return; diff --git a/src/components/views/GenreDetailView.tsx b/src/components/views/GenreDetailView.tsx index 22626d8cb..cb052fdd8 100644 --- a/src/components/views/GenreDetailView.tsx +++ b/src/components/views/GenreDetailView.tsx @@ -18,6 +18,10 @@ import { GenreImagePickerModal } from "../common/GenreImagePickerModal"; import { HiResBadge } from "../common/HiResBadge"; import { PlayingIndicator } from "../common/PlayingIndicator"; import { usePlayer } from "../../hooks/usePlayer"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { useTrackContextMenu } from "../../hooks/useTrackContextMenu"; import { useTrackUpdated } from "../../hooks/useTrackUpdated"; import { usePageVirtualizer } from "../../hooks/usePageVirtualizer"; @@ -302,6 +306,7 @@ export function DetailTrackTable({ t: (key: string, opts?: Record) => string; }) { "use no memo"; + const { value: singleClickPlay } = useSingleClickPlay(); const gridCols = "grid-cols-[3rem_2.75rem_1.5fr_1fr_1fr_5rem_2rem]"; const { parentRef, virtualizer, scrollMargin } = usePageVirtualizer({ @@ -348,7 +353,12 @@ export function DetailTrackTable({ key={`${track.id}-${index}`} tabIndex={0} role="button" - onDoubleClick={() => onPlayTrack(index)} + onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) onPlayTrack(index); + }} + onDoubleClick={() => { + if (!singleClickPlay) onPlayTrack(index); + }} onKeyDown={(e) => { // Only play when the row itself is focused — see LibraryView. if (e.target !== e.currentTarget) return; diff --git a/src/components/views/HistoryView.tsx b/src/components/views/HistoryView.tsx index b6c3cd906..a9e41be57 100644 --- a/src/components/views/HistoryView.tsx +++ b/src/components/views/HistoryView.tsx @@ -6,6 +6,10 @@ import { Artwork } from "../common/Artwork"; import { ArtistLink } from "../common/ArtistLink"; import { CreatePlaylistModal } from "../common/CreatePlaylistModal"; import { usePlayer } from "../../hooks/usePlayer"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { useTrackContextMenu } from "../../hooks/useTrackContextMenu"; import { useTrackUpdated } from "../../hooks/useTrackUpdated"; import { usePageScroll } from "../../hooks/usePageScroll"; @@ -123,6 +127,7 @@ export function HistoryView({ onNavigateToAlbum, onNavigateToArtist, }: HistoryViewProps) { + const { value: singleClickPlay } = useSingleClickPlay(); const { t, i18n } = useTranslation(); const createFromModal = useCreatePlaylistFromModal(); const locale = i18n.language; @@ -348,6 +353,7 @@ export function HistoryView({ @@ -535,11 +541,14 @@ interface DayGroupProps { onRowMenuKey: (e: React.KeyboardEvent, row: PlayHistoryRow) => boolean; onPlay: (row: PlayHistoryRow) => void; onNavigateToArtist: (artistId: number) => void; + /** Settings → single-click play, read once by the view. */ + singleClickPlay: boolean; } function DayGroup({ group, locale, + singleClickPlay, currentTrackId, onContextMenu, onRowMenuKey, @@ -581,7 +590,12 @@ function DayGroup({ tabIndex={0} aria-haspopup="menu" onContextMenu={(e) => onContextMenu(e, row)} - onDoubleClick={() => onPlay(row)} + onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) onPlay(row); + }} + onDoubleClick={() => { + if (!singleClickPlay) onPlay(row); + }} onKeyDown={(e) => { // Only act when the row itself is focused — nested // links (artist / album) handle their own keys. diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index b3baeb951..31fdde684 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -104,7 +104,7 @@ import { AlphabetIndex } from "../common/AlphabetIndex"; import { useSortIgnoreArticles } from "../../hooks/useSortIgnoreArticles"; import { useSortMemory } from "../../hooks/useSortMemory"; import { usePageScroll } from "../../hooks/usePageScroll"; -import { getProfileSetting } from "../../lib/tauri/profile"; +import { useSingleClickPlay } from "../../hooks/useSingleClickPlay"; import { useLibrary } from "../../hooks/useLibrary"; import { usePlayer } from "../../hooks/usePlayer"; import { usePlaylist } from "../../hooks/usePlaylist"; @@ -445,19 +445,7 @@ export function LibraryView({ // Single-click play. Coexists with multi-select: ctrl/shift always // takes precedence (selection), single-click triggers play only on a // bare click and only when the toggle is on. - const [singleClickPlay, setSingleClickPlay] = useState(false); - useEffect(() => { - let cancelled = false; - getProfileSetting("ui.single_click_play") - .then((v) => { - if (cancelled) return; - if (v === "true" || v === "1") setSingleClickPlay(true); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); + const { value: singleClickPlay } = useSingleClickPlay(); // Scroll target for the AlphabetIndex (artists tab uses it). // Callback ref populated by the virtualized ArtistList so the diff --git a/src/components/views/LikedView.tsx b/src/components/views/LikedView.tsx index 4b4c15c31..2e10d9726 100644 --- a/src/components/views/LikedView.tsx +++ b/src/components/views/LikedView.tsx @@ -7,6 +7,10 @@ import { ArtistLink } from "../common/ArtistLink"; import { HiResBadge } from "../common/HiResBadge"; import { CreatePlaylistModal } from "../common/CreatePlaylistModal"; import { usePlayer } from "../../hooks/usePlayer"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { useTrackContextMenu } from "../../hooks/useTrackContextMenu"; import { useTrackUpdated } from "../../hooks/useTrackUpdated"; import { useLikedChanged } from "../../hooks/useLikedTracks"; @@ -232,6 +236,7 @@ function LikedTrackList({ unlikeLabel, }: LikedTrackListProps) { "use no memo"; + const { value: singleClickPlay } = useSingleClickPlay(); const { parentRef, virtualizer, scrollMargin } = usePageVirtualizer({ count: tracks.length, @@ -260,7 +265,12 @@ function LikedTrackList({ key={track.id} tabIndex={0} role="button" - onDoubleClick={() => onPlayTrack(index)} + onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) onPlayTrack(index); + }} + onDoubleClick={() => { + if (!singleClickPlay) onPlayTrack(index); + }} onKeyDown={(e) => { // Only play when the row itself is focused — keys // fired from nested buttons / links would otherwise diff --git a/src/components/views/PlaylistView.tsx b/src/components/views/PlaylistView.tsx index 2967b4462..5c67a9f99 100644 --- a/src/components/views/PlaylistView.tsx +++ b/src/components/views/PlaylistView.tsx @@ -52,6 +52,10 @@ import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { useVirtualizer } from "@tanstack/react-virtual"; import { usePageScroll } from "../../hooks/usePageScroll"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { Artwork } from "../common/Artwork"; import { AlbumLink } from "../common/AlbumLink"; import { ArtistLink } from "../common/ArtistLink"; @@ -1893,6 +1897,7 @@ function PlaylistTrackTable({ dragEnabled, }: PlaylistTrackTableProps) { "use no memo"; + const { value: singleClickPlay } = useSingleClickPlay(); // A remote table carries a trailing remove column; the local one puts // that in the context menu. Same template otherwise, so the two read // alike column for column. @@ -2021,6 +2026,7 @@ function PlaylistTrackTable({ onRowSelect(track, e)} - onDoubleClick={() => onPlayTrack(index)} + onClick={(e) => { + if (isPlayClick(e, singleClickPlay)) { + onPlayTrack(index); + return; + } + // The second click of a double-click: the first one already + // played the row, and selecting it now would paint the selection + // over the track it started (#897). + if (singleClickPlay && e.detail > 1) return; + onRowSelect(track, e); + }} + onDoubleClick={() => { + if (!singleClickPlay) onPlayTrack(index); + }} onKeyDown={(e) => { // Only play when the row itself is focused — see LibraryView. if (e.target !== e.currentTarget) return; diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index f4e6c3b35..e9162e815 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -62,6 +62,7 @@ import { RemoteTranscodeCard } from "./settings/RemoteTranscodeCard"; import { useTheme } from "../../hooks/useTheme"; import { THEME_PRESETS } from "../../lib/themes"; import { getProfileSetting, setProfileSetting } from "../../lib/tauri/profile"; +import { useSingleClickPlay } from "../../hooks/useSingleClickPlay"; import type { ViewId } from "../../types"; import { SUPPORTED_LANGUAGES, @@ -617,7 +618,8 @@ export function SettingsView({ // window-level event the `useUiZoom` hook broadcasts. const [uiZoom, setUiZoom] = useState(1); const [scanOnStart, setScanOnStart] = useState(false); - const [singleClickPlay, setSingleClickPlay] = useState(false); + const { value: singleClickPlay, setValue: setSingleClickPlay } = + useSingleClickPlay(); const [isDuplicatesOpen, setIsDuplicatesOpen] = useState(false); const [isResetOpen, setIsResetOpen] = useState(false); // Status of the last "Copy logs" click — null when idle, "ok" or @@ -652,12 +654,6 @@ export function SettingsView({ if (v != null) setScanOnStart(v === "true" || v === "1"); }) .catch(() => {}); - getProfileSetting("ui.single_click_play") - .then((v) => { - if (cancelled) return; - if (v === "true" || v === "1") setSingleClickPlay(true); - }) - .catch(() => {}); // Sleep timer / A-B loop / audio-quality-footer visibility now // live inside `PlayerBarLayoutCard` and are read through the // shared `usePlayerBarLayout` hook — no need to hydrate them @@ -718,18 +714,11 @@ export function SettingsView({ }); }, []); + // Through the shared hook, which broadcasts the write: the lists already + // on screen pick it up without being remounted. const handleToggleSingleClickPlay = useCallback(() => { - const next = !singleClickPlay; - setSingleClickPlay(next); - setProfileSetting( - "ui.single_click_play", - next ? "true" : "false", - "bool", - ).catch((err) => { - console.error("[Settings] set single_click_play failed", err); - setSingleClickPlay(!next); - }); - }, [singleClickPlay]); + void setSingleClickPlay((previous) => !previous); + }, [setSingleClickPlay]); const handleToggleAutoStart = useCallback(() => { const next = !autoStart; diff --git a/src/hooks/useSingleClickPlay.ts b/src/hooks/useSingleClickPlay.ts new file mode 100644 index 000000000..8e1154933 --- /dev/null +++ b/src/hooks/useSingleClickPlay.ts @@ -0,0 +1,51 @@ +import type { MouseEvent } from "react"; +import { useProfileBooleanSetting } from "./useProfileSetting"; + +/** Broadcast after the preference is written, so every list re-reads it. */ +export const SINGLE_CLICK_PLAY_EVENT = "waveflow:single-click-play-changed"; + +/** + * Settings → "Single-click play": one click on a track starts it, instead + * of a double-click. Every list of tracks reads it through this hook, so a + * change made in Settings reaches the lists already on screen (#932: it was + * read by the library's song list alone, and every other list still asked + * for a double-click). + */ +export function useSingleClickPlay() { + return useProfileBooleanSetting({ + key: "ui.single_click_play", + defaultValue: false, + event: SINGLE_CLICK_PLAY_EVENT, + label: "useSingleClickPlay", + }); +} + +/** What a click on a row may land on without being a click on the row. */ +const NESTED_CONTROLS = + "button, a, input, select, textarea, label, [role='checkbox'], [role='menuitem']"; + +/** + * Whether a click on a track row should play it: single-click play is on, + * and this is a plain click on the row itself. Not the second click of a + * double-click (the first already started the track), not a click with a + * modifier (those select), and not a click on a control inside the row (a + * like button, an artist link, a drag handle), which does its own thing. + */ +export function isPlayClick( + event: MouseEvent, + singleClickPlay: boolean, +): boolean { + if (!singleClickPlay) return false; + if (event.detail > 1) return false; + if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { + return false; + } + const target = event.target; + if (!(target instanceof Element)) return true; + const control = target.closest(NESTED_CONTROLS); + return ( + control == null || + control === event.currentTarget || + !event.currentTarget.contains(control) + ); +} From b4ba3d3bf040992e0953aac9e36116625eaa2765 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 21 Oct 2026 19:43:04 +0200 Subject: [PATCH 2/2] fix(ui): let the library's song list use the shared single-click test Its own check ignored Alt, so Alt+click played there while every other list left it alone. Both of its branches now go through isPlayClick. --- src/components/views/LibraryView.tsx | 25 ++++++++++++------------- src/hooks/useSingleClickPlay.ts | 2 +- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index 31fdde684..15f72f550 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -104,7 +104,10 @@ import { AlphabetIndex } from "../common/AlphabetIndex"; import { useSortIgnoreArticles } from "../../hooks/useSortIgnoreArticles"; import { useSortMemory } from "../../hooks/useSortMemory"; import { usePageScroll } from "../../hooks/usePageScroll"; -import { useSingleClickPlay } from "../../hooks/useSingleClickPlay"; +import { + isPlayClick, + useSingleClickPlay, +} from "../../hooks/useSingleClickPlay"; import { useLibrary } from "../../hooks/useLibrary"; import { usePlayer } from "../../hooks/usePlayer"; import { usePlaylist } from "../../hooks/usePlaylist"; @@ -1364,11 +1367,9 @@ export function LibraryView({ selection.toggleOne(track.id); return; } - if (singleClickPlay) { - // The second click of a double-click: the first one already - // started the track, and the row's `dblclick` stands down in - // this mode — one gesture, one play, not three. - if (e.detail > 1) return; + // The same test every list of tracks applies: a plain click on + // the row, so Alt+click does not play here either. + if (isPlayClick(e, singleClickPlay)) { const idx = rows.findIndex( (row) => row.source === "local" && Number(row.id) === track.id, ); @@ -1376,6 +1377,10 @@ export function LibraryView({ selection.clear(); return; } + // The second click of a double-click: the first one already + // started the track, and the row's `dblclick` stands down in + // this mode — one gesture, one play, not three. + if (singleClickPlay && e.detail > 1) return; selection.setSingle(track.id); }} /> @@ -2625,13 +2630,7 @@ function TrackTable({ // rather than as an unselectable one. Modifier clicks stay // inert: they are selection gestures, and there is no // selection here to extend. - if ( - singleClickPlay && - e.detail === 1 && - !e.shiftKey && - !e.ctrlKey && - !e.metaKey - ) { + if (e.detail === 1 && isPlayClick(e, singleClickPlay)) { onPlayTrack(index); } }} diff --git a/src/hooks/useSingleClickPlay.ts b/src/hooks/useSingleClickPlay.ts index 8e1154933..c9ecb08ee 100644 --- a/src/hooks/useSingleClickPlay.ts +++ b/src/hooks/useSingleClickPlay.ts @@ -32,7 +32,7 @@ const NESTED_CONTROLS = * like button, an artist link, a drag handle), which does its own thing. */ export function isPlayClick( - event: MouseEvent, + event: MouseEvent, singleClickPlay: boolean, ): boolean { if (!singleClickPlay) return false;