fix(artist-detail): Last.fm/Wikipedia/Favorite hover keeps button border (#966)

* fix(artist-detail): keep ext-link border visible on hover

Hover used --border-subtle, which on Catppuccin matches --bg-card and
visually erased the rim while only the fill changed. Match btn-surface:
--ctp-surface1 border, --ctp-overlay0 on hover.

* docs(changelog): credit zunoz on Psysonic Discord for PR #966

* fix(playlists): tooltips on Play/Add Songs and song count pluralization

Add data-tooltip to Play and Add Songs in playlist hero; switch
playlists.songs to count-based _one/_other forms (was {{n}} without
i18next plural suffix, breaking spacing and singular).

* fix(playlists): render BPM and optional cols in Suggested Songs rows

PlaylistSuggestions shared column headers with the main tracklist but
its row switch omitted bpm, genre, playCount, and lastPlayed.
This commit is contained in:
cucadmuh
2026-06-03 23:19:51 +03:00
committed by GitHub
parent 5990d84f5a
commit f3a0b3f7af
14 changed files with 71 additions and 15 deletions
+9
View File
@@ -465,6 +465,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Genre Mix loads genres through the scoped catalog (`fetchGenreCatalog` / local index) instead of server-wide `getGenres`, matching the sidebar library filter.
### Artist detail — external link buttons keep border on hover
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#966](https://github.com/Psychotoxical/psysonic/pull/966)**
* Last.fm, Wikipedia, and Favorite used a hover border color that matched the card background — the rim disappeared instead of highlighting the inner fill like Play/Shuffle/Radio (`btn-surface`).
* Playlist detail — Play and Add Songs now show tooltips like the other header actions; track count uses proper pluralization (`1 song` vs `N songs`) with standard `count` interpolation.
* Suggested Songs rows now render BPM (and other optional columns like genre, play count, last played) — the column switch was missing those cases.
### In-page browse — virtual scroll and cover-art priority
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
+8 -2
View File
@@ -133,7 +133,7 @@ export default function PlaylistHero({
)}
</>
<div className="album-detail-info">
<span>{t('playlists.songs', { n: songs.length })}</span>
<span>{t('playlists.songs', { count: songs.length })}</span>
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{playlist.public !== undefined && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
@@ -146,7 +146,12 @@ export default function PlaylistHero({
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" disabled={songs.length === 0} onClick={handlePlayAll}>
<button
className="btn btn-primary"
disabled={songs.length === 0}
onClick={handlePlayAll}
data-tooltip={t('playlists.playTooltip')}
>
<Play size={15} /> {t('common.play', 'Reproducir')}
</button>
<button
@@ -170,6 +175,7 @@ export default function PlaylistHero({
<button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
data-tooltip={t('playlists.addSongsTooltip')}
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
@@ -11,9 +11,11 @@ import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { songToTrack } from '../../utils/playback/songToTrack';
import { getQueueTracksView } from '../../utils/library/queueTrackView';
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import { formatTrackTime } from '../../utils/format/formatDuration';
import i18n from '../../i18n';
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
interface Props {
songs: SubsonicSong[];
@@ -172,6 +174,18 @@ export default function PlaylistSuggestions({
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'genre': return (
<div key="genre" className="track-genre">{song.genre ?? '—'}</div>
);
case 'playCount': return (
<div key="playCount" className="track-duration">{song.playCount ?? '—'}</div>
);
case 'lastPlayed': return (
<div key="lastPlayed" className="track-genre">{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}</div>
);
case 'bpm': return (
<div key="bpm" className="track-duration">{song.bpm && song.bpm > 0 ? song.bpm : '—'}</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
+1 -1
View File
@@ -161,7 +161,7 @@ export default function PlaylistCard({
<span>{displayPlaylistName(pl.name)}</span>
</div>
<div className="album-card-artist">
{t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{t('playlists.songs', { count: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })}
{(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)}
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Diese Playlist ist leer.',
addFirstSong: 'Ersten Song hinzufügen',
notFound: 'Playlist nicht gefunden.',
songs: '{{n}} Songs',
songs_one: '{{count}} Song',
songs_other: '{{count}} Songs',
playAll: 'Alle abspielen',
playTooltip: 'Playlist abspielen',
shuffle: 'Zufallswiedergabe',
addToQueue: 'Zur Warteschlange',
back: 'Zurück zu Playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Nochmals klicken zum Bestätigen',
removeSong: 'Aus Playlist entfernen',
addSongs: 'Songs hinzufügen',
addSongsTooltip: 'Bibliothek durchsuchen und Titel hinzufügen',
searchPlaceholder: 'Bibliothek durchsuchen…',
noResults: 'Keine Ergebnisse.',
suggestions: 'Vorgeschlagene Songs',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'This playlist is empty.',
addFirstSong: 'Add your first song',
notFound: 'Playlist not found.',
songs: '{{n}} songs',
songs_one: '{{count}} song',
songs_other: '{{count}} songs',
playAll: 'Play All',
playTooltip: 'Play playlist',
shuffle: 'Shuffle',
addToQueue: 'Add to Queue',
back: 'Back to Playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Click again to confirm',
removeSong: 'Remove from playlist',
addSongs: 'Add Songs',
addSongsTooltip: 'Search your library to add tracks',
searchPlaceholder: 'Search your library…',
noResults: 'No results.',
suggestions: 'Suggested Songs',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Esta lista está vacía.',
addFirstSong: 'Agrega tu primera canción',
notFound: 'Lista no encontrada.',
songs: '{{n}} canciones',
songs_one: '{{count}} canción',
songs_other: '{{count}} canciones',
playAll: 'Reproducir Todo',
playTooltip: 'Reproducir lista',
shuffle: 'Aleatorio',
addToQueue: 'Agregar a Cola',
back: 'Volver a Listas',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Click de nuevo para confirmar',
removeSong: 'Quitar de lista',
addSongs: 'Agregar Canciones',
addSongsTooltip: 'Buscar en la biblioteca y agregar canciones',
searchPlaceholder: 'Buscar en tu biblioteca…',
noResults: 'Sin resultados.',
suggestions: 'Canciones Sugeridas',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Cette playlist est vide.',
addFirstSong: 'Ajouter votre premier titre',
notFound: 'Playlist introuvable.',
songs: '{{n}} titres',
songs_one: '{{count}} titre',
songs_other: '{{count}} titres',
playAll: 'Tout lire',
playTooltip: 'Lire la playlist',
shuffle: 'Aléatoire',
addToQueue: 'Ajouter à la file',
back: 'Retour aux playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Cliquer à nouveau pour confirmer',
removeSong: 'Retirer de la playlist',
addSongs: 'Ajouter des titres',
addSongsTooltip: 'Rechercher dans la bibliothèque et ajouter des titres',
searchPlaceholder: 'Rechercher dans la bibliothèque…',
noResults: 'Aucun résultat.',
suggestions: 'Titres suggérés',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Denne spillelisten er tom.',
addFirstSong: 'Legg til din første sang',
notFound: 'Spillelisten ble ikke funnet.',
songs: '{{n}} sanger',
songs_one: '{{count}} sang',
songs_other: '{{count}} sanger',
playAll: 'Spill alle',
playTooltip: 'Spill spilleliste',
shuffle: 'Bland',
addToQueue: 'Legg til i kø',
back: 'Tilbake til spillelister',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Klikk igjen for å bekrefte',
removeSong: 'Fjern fra spilleliste',
addSongs: 'Legg til sanger',
addSongsTooltip: 'Søk i biblioteket og legg til sanger',
searchPlaceholder: 'Søk i biblioteket ditt…',
noResults: 'Ingen resultater.',
suggestions: 'Foreslåtte sanger',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Deze playlist is leeg.',
addFirstSong: 'Voeg je eerste nummer toe',
notFound: 'Playlist niet gevonden.',
songs: '{{n}} nummers',
songs_one: '{{count}} nummer',
songs_other: '{{count}} nummers',
playAll: 'Alles afspelen',
playTooltip: 'Playlist afspelen',
shuffle: 'Willekeurig',
addToQueue: 'Aan wachtrij toevoegen',
back: 'Terug naar playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Nogmaals klikken om te bevestigen',
removeSong: 'Uit playlist verwijderen',
addSongs: 'Nummers toevoegen',
addSongsTooltip: 'Bibliotheek doorzoeken en nummers toevoegen',
searchPlaceholder: 'Doorzoek bibliotheek…',
noResults: 'Geen resultaten.',
suggestions: 'Aanbevolen nummers',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Acest playlist este gol.',
addFirstSong: 'Adaugă prima ta piesă',
notFound: 'Playlistul nu a fost găsit.',
songs: '{{n}} piese',
songs_one: '{{count}} piesă',
songs_other: '{{count}} piese',
playAll: 'Redă tot',
playTooltip: 'Redă playlistul',
shuffle: 'Amestecă',
addToQueue: 'Adaugă la Coadă',
back: 'Înapoi la Playlisturi',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Apasă din nou pentru a confirma',
removeSong: 'Șterge din playlist',
addSongs: 'Adaugă Piese',
addSongsTooltip: 'Caută în librărie și adaugă piese',
searchPlaceholder: 'Caută în librărie…',
noResults: 'Niciun rezultat.',
suggestions: 'Piese sugerate',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Плейлист пуст.',
addFirstSong: 'Добавьте первый трек',
notFound: 'Плейлист не найден.',
songs: 'Треков: {{n}}',
songs_one: '{{count}} трек',
songs_other: '{{count}} треков',
playAll: 'Воспроизвести всё',
playTooltip: 'Воспроизвести плейлист',
shuffle: 'Перемешать',
addToQueue: 'В очередь',
back: 'К списку плейлистов',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Нажмите ещё раз для подтверждения',
removeSong: 'Убрать из плейлиста',
addSongs: 'Добавить треки',
addSongsTooltip: 'Найти треки в библиотеке и добавить в плейлист',
searchPlaceholder: 'Поиск по библиотеке…',
noResults: 'Ничего не найдено.',
suggestions: 'Рекомендации',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: '此播放列表为空。',
addFirstSong: '添加第一首歌曲',
notFound: '未找到播放列表。',
songs: '{{n}} 首歌曲',
songs_one: '{{count}} 首歌曲',
songs_other: '{{count}} 首歌曲',
playAll: '全部播放',
playTooltip: '播放播放列表',
shuffle: '随机播放',
addToQueue: '添加到队列',
back: '返回播放列表',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: '再次点击确认删除',
removeSong: '从播放列表中移除',
addSongs: '添加歌曲',
addSongsTooltip: '搜索音乐库并添加歌曲',
searchPlaceholder: '搜索音乐库…',
noResults: '无结果。',
suggestions: '推荐歌曲',
@@ -216,7 +216,7 @@
padding: 5px 12px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
border: 1px solid var(--ctp-surface1);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
@@ -227,7 +227,7 @@
.artist-ext-link:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-subtle);
border-color: var(--ctp-overlay0);
}
/* Bio section */