Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Slim>[] }` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/components/layout/QueuePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -484,6 +488,7 @@ function SortableUpNext({
onRowMenuKey,
}: SortableUpNextProps) {
"use no memo";
const { value: singleClickPlay } = useSingleClickPlay();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
Expand Down Expand Up @@ -569,6 +574,7 @@ function SortableUpNext({
<SortableQueueRow
key={absoluteIndex}
id={String(absoluteIndex)}
singleClickPlay={singleClickPlay}
absoluteIndex={absoluteIndex}
item={item}
top={virtualRow.start}
Expand Down Expand Up @@ -644,6 +650,7 @@ function QueueRowPreview({ item }: { item: QueueTrackPayload }) {
*/
const SortableQueueRow = memo(function SortableQueueRow({
id,
singleClickPlay,
absoluteIndex,
item,
top,
Expand All @@ -653,6 +660,8 @@ const SortableQueueRow = memo(function SortableQueueRow({
onRowMenuKey,
}: {
id: string;
/** Settings → single-click play, read once by the list. */
singleClickPlay: boolean;
absoluteIndex: number;
item: QueueTrackPayload;
top: number;
Expand Down Expand Up @@ -701,7 +710,12 @@ const SortableQueueRow = memo(function SortableQueueRow({
specific compound; keyboard activation still works via the
tabIndex + role + onKeyDown wiring. */}
<div
onDoubleClick={() => onJump(absoluteIndex)}
onClick={(e) => {
if (isPlayClick(e, singleClickPlay)) onJump(absoluteIndex);
}}
onDoubleClick={() => {
if (!singleClickPlay) onJump(absoluteIndex);
}}
onContextMenu={(e) => onContextMenu(e, item)}
tabIndex={0}
role="button"
Expand Down
16 changes: 15 additions & 1 deletion src/components/player/ImmersiveQueueTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +25,7 @@ import {
*/
export function ImmersiveQueueTab() {
const { t } = useTranslation();
const { value: singleClickPlay } = useSingleClickPlay();
const [snapshot, setSnapshot] = useState<PlayerQueueSnapshot | null>(null);

// Seq guard so overlapping refetches (rapid Next clicks fire many
Expand Down Expand Up @@ -131,6 +136,7 @@ export function ImmersiveQueueTab() {
<ImmersiveQueueRow
key={absoluteIndex}
item={item}
singleClickPlay={singleClickPlay}
onJump={() => handleJump(absoluteIndex)}
/>
);
Expand All @@ -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 (
<div
onDoubleClick={onJump}
onClick={(e) => {
if (onJump && isPlayClick(e, singleClickPlay)) onJump();
}}
onDoubleClick={() => {
if (!singleClickPlay) onJump?.();
}}
tabIndex={interactive ? 0 : undefined}
role={interactive ? "button" : undefined}
onKeyDown={
Expand Down
16 changes: 15 additions & 1 deletion src/components/views/AlbumDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/components/views/ArtistDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -957,6 +961,7 @@ function ArtistTrackTable({
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
"use no memo";
const { value: singleClickPlay } = useSingleClickPlay();
const gridCols = "grid-cols-[3rem_2.75rem_1fr_1fr_5rem_2rem]";
const { parentRef, virtualizer, scrollMargin } =
usePageVirtualizer<HTMLUListElement>({
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading