fix(favorites): artist link no-play + inline bulk action chips (#746)

* fix(favorites): artist link in songs table no longer triggers playback

The artist cell's click handler navigated without `stopPropagation`, so
the row's onClick still fired and started the track. Matches the album
cell's behaviour and the other tracklists (SongRow, PlaylistTracklist,
PlaylistSuggestions, SongCard, AlbumCard) — all of which already guard
the navigation click.

* fix(favorites): inline bulk action chips in section header

The full-width bulk-action bar above the column header pushed every row
down by ~36 px when an item was selected, making it harder to pick
adjacent items. Move the "X selected / Add to playlist / Clear" cluster
into the existing action-buttons row (right-aligned via margin-left:
auto), matching the album toolbar pattern. Selection mode no longer
shifts the rows. Clear-selection button uses `btn-surface` to match the
other secondary actions on the page (post-#745 convention).

* docs(changelog): note Favorites artist-link + bulk-bar fixes (#746)
This commit is contained in:
Frank Stellmacher
2026-05-17 12:55:31 +02:00
committed by GitHub
parent 3b94368ffa
commit 2beb30f871
4 changed files with 63 additions and 40 deletions
+7
View File
@@ -638,6 +638,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
* Hero pills stay visible against light-toned cover art (opaque fill), and the pagination dots are readable on every backdrop (brighter inactive dot with a dark outline, accent-coloured active dot). * Hero pills stay visible against light-toned cover art (opaque fill), and the pagination dots are readable on every backdrop (brighter inactive dot with a dark outline, accent-coloured active dot).
* Composers grid no longer reserves ~200 px per virtual row for ~78 px text-only tiles. The Tracks "browse all" header now lives inside the scroll container so columns line up with the rows under wider fonts like **OpenDyslexic**, and the header stays pinned while scrolling. * Composers grid no longer reserves ~200 px per virtual row for ~78 px text-only tiles. The Tracks "browse all" header now lives inside the scroll container so columns line up with the rows under wider fonts like **OpenDyslexic**, and the header stays pinned while scrolling.
### Favorites — artist link no longer triggers playback, bulk selection no longer shifts the rows
**By [@Psychotoxical](https://github.com/Psychotoxical), thanks to zunoz for the report on the Psysonic Discord, PR [#746](https://github.com/Psychotoxical/psysonic/pull/746)**
* Clicking the **artist** in the Favorites songs table opened the artist page _and_ started the song — the cell was missing the click guard the album cell already had. Now matches every other tracklist in the app.
* Selecting a song no longer pushes the column header and every row down by one line. The "X selected / Add to playlist / Clear" cluster moved out of the full-width bar into the existing action-buttons row (right-aligned), matching the album toolbar, so the next item stays under the same cursor position.
## [1.45.0] - 2026-05-04 ## [1.45.0] - 2026-05-04
## Added ## Added
@@ -3,7 +3,9 @@ import { useTranslation } from 'react-i18next';
import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react'; import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore'; import { usePlayerStore } from '../../store/playerStore';
import { useSelectionStore } from '../../store/selectionStore';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { AddToPlaylistSubmenu } from '../ContextMenu';
import GenreFilterBar from '../GenreFilterBar'; import GenreFilterBar from '../GenreFilterBar';
interface Props { interface Props {
@@ -25,6 +27,10 @@ interface Props {
starredOverrides: Record<string, boolean>; starredOverrides: Record<string, boolean>;
minYear: number; minYear: number;
currentYear: number; currentYear: number;
inSelectMode: boolean;
selectedCount: number;
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
} }
export default function FavoritesSongsSectionHeader({ export default function FavoritesSongsSectionHeader({
@@ -32,6 +38,7 @@ export default function FavoritesSongsSectionHeader({
selectedGenres, setSelectedGenres, yearRange, setYearRange, selectedGenres, setSelectedGenres, yearRange, setYearRange,
showFilters, setShowFilters, setSortKey, setSortClickCount, showFilters, setShowFilters, setSortKey, setSortClickCount,
playTrack, enqueue, starredOverrides, minYear, currentYear, playTrack, enqueue, starredOverrides, minYear, currentYear,
inSelectMode, selectedCount, showPlPicker, setShowPlPicker,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -100,6 +107,39 @@ export default function FavoritesSongsSectionHeader({
{t('common.clearAll')} {t('common.clearAll')}
</button> </button>
)} )}
{/* Bulk action chips — inline at row end so a selection does not
push the column header / rows downward (matches Album toolbar). */}
{inSelectMode && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginLeft: 'auto' }}>
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-surface btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
</div> </div>
{/* Filters Panel */} {/* Filters Panel */}
@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
AudioLines, Check, ChevronDown, ChevronRight, ListPlus, Play, RotateCcw, AudioLines, Check, ChevronDown, ChevronRight, Play, RotateCcw,
Square, X, Square, X,
} from 'lucide-react'; } from 'lucide-react';
import type { ColDef } from '../../utils/useTracklistColumns'; import type { ColDef } from '../../utils/useTracklistColumns';
@@ -16,7 +16,6 @@ import { songToTrack } from '../../utils/playback/songToTrack';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers'; import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import i18n from '../../i18n'; import i18n from '../../i18n';
import { AddToPlaylistSubmenu } from '../ContextMenu';
import StarRating from '../StarRating'; import StarRating from '../StarRating';
const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']); const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
@@ -27,8 +26,6 @@ interface Props {
selectedCount: number; selectedCount: number;
inSelectMode: boolean; inSelectMode: boolean;
toggleSelect: (id: string, idx: number, shift: boolean) => void; toggleSelect: (id: string, idx: number, shift: boolean) => void;
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
allColumns: readonly ColDef[]; allColumns: readonly ColDef[];
visibleCols: ColDef[]; visibleCols: ColDef[];
gridStyle: React.CSSProperties; gridStyle: React.CSSProperties;
@@ -50,7 +47,6 @@ interface Props {
export default function FavoritesSongsTracklist({ export default function FavoritesSongsTracklist({
visibleSongs, selectedIds, selectedCount, inSelectMode, toggleSelect, visibleSongs, selectedIds, selectedCount, inSelectMode, toggleSelect,
showPlPicker, setShowPlPicker,
allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns, allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef, pickerOpen, setPickerOpen, pickerRef, tracklistRef,
startResize, handleSortClick, getSortIndicator, startResize, handleSortClick, getSortIndicator,
@@ -73,38 +69,6 @@ export default function FavoritesSongsTracklist({
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}> }}>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* Column visibility picker */} {/* Column visibility picker */}
<div className="tracklist-col-picker-wrapper" ref={pickerRef}> <div className="tracklist-col-picker-wrapper" ref={pickerRef}>
<div className="tracklist-col-picker"> <div className="tracklist-col-picker">
@@ -306,7 +270,17 @@ export default function FavoritesSongsTracklist({
); );
case 'artist': return ( case 'artist': return (
<div key="artist" className="track-artist-cell"> <div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span> <span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={(e) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
}}
>
{song.artist}
</span>
</div> </div>
); );
case 'album': return ( case 'album': return (
+4 -2
View File
@@ -194,6 +194,10 @@ export default function Favorites() {
starredOverrides={starredOverrides} starredOverrides={starredOverrides}
minYear={MIN_YEAR} minYear={MIN_YEAR}
currentYear={CURRENT_YEAR} currentYear={CURRENT_YEAR}
inSelectMode={inSelectMode}
selectedCount={selectedCount}
showPlPicker={showPlPicker}
setShowPlPicker={setShowPlPicker}
/> />
<FavoritesSongsTracklist <FavoritesSongsTracklist
visibleSongs={visibleSongs} visibleSongs={visibleSongs}
@@ -201,8 +205,6 @@ export default function Favorites() {
selectedCount={selectedCount} selectedCount={selectedCount}
inSelectMode={inSelectMode} inSelectMode={inSelectMode}
toggleSelect={toggleSelect} toggleSelect={toggleSelect}
showPlPicker={showPlPicker}
setShowPlPicker={setShowPlPicker}
allColumns={FAV_COLUMNS} allColumns={FAV_COLUMNS}
visibleCols={visibleCols} visibleCols={visibleCols}
gridStyle={gridStyle} gridStyle={gridStyle}