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. * 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 ### In-page browse — virtual scroll and cover-art priority
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** **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"> <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>} {songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{playlist.public !== undefined && ( {playlist.public !== undefined && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
@@ -146,7 +146,12 @@ export default function PlaylistHero({
</div> </div>
<div className="album-detail-actions"> <div className="album-detail-actions">
<div className="album-detail-actions-primary"> <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')} <Play size={15} /> {t('common.play', 'Reproducir')}
</button> </button>
<button <button
@@ -170,6 +175,7 @@ export default function PlaylistHero({
<button <button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`} className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }} onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
data-tooltip={t('playlists.addSongsTooltip')}
> >
<Search size={16} /> {t('playlists.addSongs')} <Search size={16} /> {t('playlists.addSongs')}
</button> </button>
@@ -11,9 +11,11 @@ import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { songToTrack } from '../../utils/playback/songToTrack'; import { songToTrack } from '../../utils/playback/songToTrack';
import { getQueueTracksView } from '../../utils/library/queueTrackView'; import { getQueueTracksView } from '../../utils/library/queueTrackView';
import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers'; import { codecLabel } from '../../utils/componentHelpers/playlistDetailHelpers';
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import { formatTrackTime } from '../../utils/format/formatDuration'; 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 { interface Props {
songs: SubsonicSong[]; songs: SubsonicSong[];
@@ -172,6 +174,18 @@ export default function PlaylistSuggestions({
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>} {(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div> </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 ( case 'delete': return (
<div key="delete" className="playlist-row-delete-cell"> <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"> <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> <span>{displayPlaylistName(pl.name)}</span>
</div> </div>
<div className="album-card-artist"> <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 && ( {(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && (
<> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</> <> · {formatHumanHoursMinutes(filteredDurationByPlaylist[pl.id] ?? pl.duration)}</>
)} )}
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Diese Playlist ist leer.', emptyPlaylist: 'Diese Playlist ist leer.',
addFirstSong: 'Ersten Song hinzufügen', addFirstSong: 'Ersten Song hinzufügen',
notFound: 'Playlist nicht gefunden.', notFound: 'Playlist nicht gefunden.',
songs: '{{n}} Songs', songs_one: '{{count}} Song',
songs_other: '{{count}} Songs',
playAll: 'Alle abspielen', playAll: 'Alle abspielen',
playTooltip: 'Playlist abspielen',
shuffle: 'Zufallswiedergabe', shuffle: 'Zufallswiedergabe',
addToQueue: 'Zur Warteschlange', addToQueue: 'Zur Warteschlange',
back: 'Zurück zu Playlists', back: 'Zurück zu Playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Nochmals klicken zum Bestätigen', confirmDelete: 'Nochmals klicken zum Bestätigen',
removeSong: 'Aus Playlist entfernen', removeSong: 'Aus Playlist entfernen',
addSongs: 'Songs hinzufügen', addSongs: 'Songs hinzufügen',
addSongsTooltip: 'Bibliothek durchsuchen und Titel hinzufügen',
searchPlaceholder: 'Bibliothek durchsuchen…', searchPlaceholder: 'Bibliothek durchsuchen…',
noResults: 'Keine Ergebnisse.', noResults: 'Keine Ergebnisse.',
suggestions: 'Vorgeschlagene Songs', suggestions: 'Vorgeschlagene Songs',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'This playlist is empty.', emptyPlaylist: 'This playlist is empty.',
addFirstSong: 'Add your first song', addFirstSong: 'Add your first song',
notFound: 'Playlist not found.', notFound: 'Playlist not found.',
songs: '{{n}} songs', songs_one: '{{count}} song',
songs_other: '{{count}} songs',
playAll: 'Play All', playAll: 'Play All',
playTooltip: 'Play playlist',
shuffle: 'Shuffle', shuffle: 'Shuffle',
addToQueue: 'Add to Queue', addToQueue: 'Add to Queue',
back: 'Back to Playlists', back: 'Back to Playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Click again to confirm', confirmDelete: 'Click again to confirm',
removeSong: 'Remove from playlist', removeSong: 'Remove from playlist',
addSongs: 'Add Songs', addSongs: 'Add Songs',
addSongsTooltip: 'Search your library to add tracks',
searchPlaceholder: 'Search your library…', searchPlaceholder: 'Search your library…',
noResults: 'No results.', noResults: 'No results.',
suggestions: 'Suggested Songs', suggestions: 'Suggested Songs',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Esta lista está vacía.', emptyPlaylist: 'Esta lista está vacía.',
addFirstSong: 'Agrega tu primera canción', addFirstSong: 'Agrega tu primera canción',
notFound: 'Lista no encontrada.', notFound: 'Lista no encontrada.',
songs: '{{n}} canciones', songs_one: '{{count}} canción',
songs_other: '{{count}} canciones',
playAll: 'Reproducir Todo', playAll: 'Reproducir Todo',
playTooltip: 'Reproducir lista',
shuffle: 'Aleatorio', shuffle: 'Aleatorio',
addToQueue: 'Agregar a Cola', addToQueue: 'Agregar a Cola',
back: 'Volver a Listas', back: 'Volver a Listas',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Click de nuevo para confirmar', confirmDelete: 'Click de nuevo para confirmar',
removeSong: 'Quitar de lista', removeSong: 'Quitar de lista',
addSongs: 'Agregar Canciones', addSongs: 'Agregar Canciones',
addSongsTooltip: 'Buscar en la biblioteca y agregar canciones',
searchPlaceholder: 'Buscar en tu biblioteca…', searchPlaceholder: 'Buscar en tu biblioteca…',
noResults: 'Sin resultados.', noResults: 'Sin resultados.',
suggestions: 'Canciones Sugeridas', suggestions: 'Canciones Sugeridas',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Cette playlist est vide.', emptyPlaylist: 'Cette playlist est vide.',
addFirstSong: 'Ajouter votre premier titre', addFirstSong: 'Ajouter votre premier titre',
notFound: 'Playlist introuvable.', notFound: 'Playlist introuvable.',
songs: '{{n}} titres', songs_one: '{{count}} titre',
songs_other: '{{count}} titres',
playAll: 'Tout lire', playAll: 'Tout lire',
playTooltip: 'Lire la playlist',
shuffle: 'Aléatoire', shuffle: 'Aléatoire',
addToQueue: 'Ajouter à la file', addToQueue: 'Ajouter à la file',
back: 'Retour aux playlists', back: 'Retour aux playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Cliquer à nouveau pour confirmer', confirmDelete: 'Cliquer à nouveau pour confirmer',
removeSong: 'Retirer de la playlist', removeSong: 'Retirer de la playlist',
addSongs: 'Ajouter des titres', addSongs: 'Ajouter des titres',
addSongsTooltip: 'Rechercher dans la bibliothèque et ajouter des titres',
searchPlaceholder: 'Rechercher dans la bibliothèque…', searchPlaceholder: 'Rechercher dans la bibliothèque…',
noResults: 'Aucun résultat.', noResults: 'Aucun résultat.',
suggestions: 'Titres suggérés', suggestions: 'Titres suggérés',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Denne spillelisten er tom.', emptyPlaylist: 'Denne spillelisten er tom.',
addFirstSong: 'Legg til din første sang', addFirstSong: 'Legg til din første sang',
notFound: 'Spillelisten ble ikke funnet.', notFound: 'Spillelisten ble ikke funnet.',
songs: '{{n}} sanger', songs_one: '{{count}} sang',
songs_other: '{{count}} sanger',
playAll: 'Spill alle', playAll: 'Spill alle',
playTooltip: 'Spill spilleliste',
shuffle: 'Bland', shuffle: 'Bland',
addToQueue: 'Legg til i kø', addToQueue: 'Legg til i kø',
back: 'Tilbake til spillelister', back: 'Tilbake til spillelister',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Klikk igjen for å bekrefte', confirmDelete: 'Klikk igjen for å bekrefte',
removeSong: 'Fjern fra spilleliste', removeSong: 'Fjern fra spilleliste',
addSongs: 'Legg til sanger', addSongs: 'Legg til sanger',
addSongsTooltip: 'Søk i biblioteket og legg til sanger',
searchPlaceholder: 'Søk i biblioteket ditt…', searchPlaceholder: 'Søk i biblioteket ditt…',
noResults: 'Ingen resultater.', noResults: 'Ingen resultater.',
suggestions: 'Foreslåtte sanger', suggestions: 'Foreslåtte sanger',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Deze playlist is leeg.', emptyPlaylist: 'Deze playlist is leeg.',
addFirstSong: 'Voeg je eerste nummer toe', addFirstSong: 'Voeg je eerste nummer toe',
notFound: 'Playlist niet gevonden.', notFound: 'Playlist niet gevonden.',
songs: '{{n}} nummers', songs_one: '{{count}} nummer',
songs_other: '{{count}} nummers',
playAll: 'Alles afspelen', playAll: 'Alles afspelen',
playTooltip: 'Playlist afspelen',
shuffle: 'Willekeurig', shuffle: 'Willekeurig',
addToQueue: 'Aan wachtrij toevoegen', addToQueue: 'Aan wachtrij toevoegen',
back: 'Terug naar playlists', back: 'Terug naar playlists',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Nogmaals klikken om te bevestigen', confirmDelete: 'Nogmaals klikken om te bevestigen',
removeSong: 'Uit playlist verwijderen', removeSong: 'Uit playlist verwijderen',
addSongs: 'Nummers toevoegen', addSongs: 'Nummers toevoegen',
addSongsTooltip: 'Bibliotheek doorzoeken en nummers toevoegen',
searchPlaceholder: 'Doorzoek bibliotheek…', searchPlaceholder: 'Doorzoek bibliotheek…',
noResults: 'Geen resultaten.', noResults: 'Geen resultaten.',
suggestions: 'Aanbevolen nummers', suggestions: 'Aanbevolen nummers',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Acest playlist este gol.', emptyPlaylist: 'Acest playlist este gol.',
addFirstSong: 'Adaugă prima ta piesă', addFirstSong: 'Adaugă prima ta piesă',
notFound: 'Playlistul nu a fost găsit.', notFound: 'Playlistul nu a fost găsit.',
songs: '{{n}} piese', songs_one: '{{count}} piesă',
songs_other: '{{count}} piese',
playAll: 'Redă tot', playAll: 'Redă tot',
playTooltip: 'Redă playlistul',
shuffle: 'Amestecă', shuffle: 'Amestecă',
addToQueue: 'Adaugă la Coadă', addToQueue: 'Adaugă la Coadă',
back: 'Înapoi la Playlisturi', back: 'Înapoi la Playlisturi',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Apasă din nou pentru a confirma', confirmDelete: 'Apasă din nou pentru a confirma',
removeSong: 'Șterge din playlist', removeSong: 'Șterge din playlist',
addSongs: 'Adaugă Piese', addSongs: 'Adaugă Piese',
addSongsTooltip: 'Caută în librărie și adaugă piese',
searchPlaceholder: 'Caută în librărie…', searchPlaceholder: 'Caută în librărie…',
noResults: 'Niciun rezultat.', noResults: 'Niciun rezultat.',
suggestions: 'Piese sugerate', suggestions: 'Piese sugerate',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: 'Плейлист пуст.', emptyPlaylist: 'Плейлист пуст.',
addFirstSong: 'Добавьте первый трек', addFirstSong: 'Добавьте первый трек',
notFound: 'Плейлист не найден.', notFound: 'Плейлист не найден.',
songs: 'Треков: {{n}}', songs_one: '{{count}} трек',
songs_other: '{{count}} треков',
playAll: 'Воспроизвести всё', playAll: 'Воспроизвести всё',
playTooltip: 'Воспроизвести плейлист',
shuffle: 'Перемешать', shuffle: 'Перемешать',
addToQueue: 'В очередь', addToQueue: 'В очередь',
back: 'К списку плейлистов', back: 'К списку плейлистов',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: 'Нажмите ещё раз для подтверждения', confirmDelete: 'Нажмите ещё раз для подтверждения',
removeSong: 'Убрать из плейлиста', removeSong: 'Убрать из плейлиста',
addSongs: 'Добавить треки', addSongs: 'Добавить треки',
addSongsTooltip: 'Найти треки в библиотеке и добавить в плейлист',
searchPlaceholder: 'Поиск по библиотеке…', searchPlaceholder: 'Поиск по библиотеке…',
noResults: 'Ничего не найдено.', noResults: 'Ничего не найдено.',
suggestions: 'Рекомендации', suggestions: 'Рекомендации',
+4 -1
View File
@@ -9,8 +9,10 @@ export const playlists = {
emptyPlaylist: '此播放列表为空。', emptyPlaylist: '此播放列表为空。',
addFirstSong: '添加第一首歌曲', addFirstSong: '添加第一首歌曲',
notFound: '未找到播放列表。', notFound: '未找到播放列表。',
songs: '{{n}} 首歌曲', songs_one: '{{count}} 首歌曲',
songs_other: '{{count}} 首歌曲',
playAll: '全部播放', playAll: '全部播放',
playTooltip: '播放播放列表',
shuffle: '随机播放', shuffle: '随机播放',
addToQueue: '添加到队列', addToQueue: '添加到队列',
back: '返回播放列表', back: '返回播放列表',
@@ -18,6 +20,7 @@ export const playlists = {
confirmDelete: '再次点击确认删除', confirmDelete: '再次点击确认删除',
removeSong: '从播放列表中移除', removeSong: '从播放列表中移除',
addSongs: '添加歌曲', addSongs: '添加歌曲',
addSongsTooltip: '搜索音乐库并添加歌曲',
searchPlaceholder: '搜索音乐库…', searchPlaceholder: '搜索音乐库…',
noResults: '无结果。', noResults: '无结果。',
suggestions: '推荐歌曲', suggestions: '推荐歌曲',
@@ -216,7 +216,7 @@
padding: 5px 12px; padding: 5px 12px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--ctp-surface1);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -227,7 +227,7 @@
.artist-ext-link:hover { .artist-ext-link:hover {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-primary); color: var(--text-primary);
border-color: var(--border-subtle); border-color: var(--ctp-overlay0);
} }
/* Bio section */ /* Bio section */