Distinct circular song cards with jump-to-album badge (#953)

* feat(tracks): distinct circular song cards with jump-to-album badge

Single-track cards looked identical to album tiles, so clicking the body
read as album behaviour even though it starts playback. Give them a round
vinyl-style cover (square stays album-only) via a shared .cover-circle
utility, and add a 'To album' badge under the artist that navigates to the
track's album. Card click still plays; the badge is the explicit nav path.

* i18n(tracks): add toAlbum label across 9 locales

* docs(changelog): track-card redesign + to-album badge (#953)
This commit is contained in:
Frank Stellmacher
2026-06-02 20:34:54 +02:00
committed by GitHub
parent 47832632fd
commit cc04a0c93d
13 changed files with 100 additions and 10 deletions
+7
View File
@@ -394,6 +394,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Analysis **tpm** and cover **cpm** (lib + ui) now measure throughput over the trailing **5 seconds** instead of a full-minute rolling average. The figure is still extrapolated to per-minute, but reacts promptly to bursts/stalls and decays to 0 within the window when idle, instead of coasting on minute-long inertia.
### Track cards — distinct look + jump to album
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#953](https://github.com/Psychotoxical/psysonic/pull/953)**
* Single tracks in the discovery rails now show a round, vinyl-style cover so they read as songs rather than albums — clicking one still plays it instantly.
* A new **To album** badge under the artist jumps to the track's album, available in all 9 languages.
+22 -2
View File
@@ -2,7 +2,7 @@ import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, Star } from 'lucide-react';
import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { CoverArtImage } from '../cover/CoverArtImage';
@@ -12,6 +12,7 @@ import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
interface SongCardProps {
song: SubsonicSong;
@@ -41,6 +42,7 @@ function SongCard({
const coverUrl = coverHandle.src;
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const navigateToAlbum = useNavigateToAlbum();
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
@@ -60,6 +62,12 @@ function SongCard({
navigate(`/artist/${song.artistId}`);
};
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
e.stopPropagation();
navigateToAlbum(song.albumId);
};
return (
<div
className="song-card card"
@@ -93,7 +101,7 @@ function SongCard({
document.addEventListener('mouseup', onUp);
}}
>
<div className="song-card-cover">
<div className="song-card-cover cover-circle">
{!disableArtwork && coverRef ? (
<CoverArtImage
coverRef={coverRef}
@@ -140,6 +148,18 @@ function SongCard({
onClick={handleArtistClick}
title={song.artist}
>{song.artist}</p>
{song.albumId && (
<button
type="button"
className="song-card-album-badge"
onClick={handleAlbumClick}
aria-label={`${t('tracks.toAlbum')} ${song.album}`}
title={song.album}
>
<Disc3 size={11} />
<span>{t('tracks.toAlbum')}</span>
</button>
)}
{(song.userRating ?? 0) > 0 && (
<div className="song-card-rating" aria-label={`${song.userRating} stars`}>
{Array.from({ length: 5 }, (_, i) => (
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Anderen wählen',
playSong: 'Abspielen',
enqueueSong: 'Zur Warteschlange',
toAlbum: 'Zum Album',
railRandom: 'Zufallsauswahl',
railHighlyRated: 'Hoch bewertet',
browseTitle: 'Alle Titel durchstöbern',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Pick another',
playSong: 'Play',
enqueueSong: 'Add to queue',
toAlbum: 'To album',
railRandom: 'Random Pick',
railHighlyRated: 'Highly Rated',
browseTitle: 'Browse all tracks',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Elegir otra',
playSong: 'Reproducir',
enqueueSong: 'Añadir a la cola',
toAlbum: 'Ver álbum',
railRandom: 'Selección aleatoria',
railHighlyRated: 'Mejor valoradas',
browseTitle: 'Explorar todas las canciones',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'En choisir un autre',
playSong: 'Lire',
enqueueSong: 'Ajouter à la file',
toAlbum: "Voir l'album",
railRandom: 'Sélection aléatoire',
railHighlyRated: 'Mieux notés',
browseTitle: 'Parcourir tous les titres',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Velg et annet',
playSong: 'Spill av',
enqueueSong: 'Legg til i kø',
toAlbum: 'Til album',
railRandom: 'Tilfeldig valg',
railHighlyRated: 'Høyt vurdert',
browseTitle: 'Bla gjennom alle spor',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Een ander kiezen',
playSong: 'Afspelen',
enqueueSong: 'Aan wachtrij toevoegen',
toAlbum: 'Naar album',
railRandom: 'Willekeurige selectie',
railHighlyRated: 'Hoog beoordeeld',
browseTitle: 'Alle nummers doorbladeren',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Alege alta',
playSong: 'Redă',
enqueueSong: 'Adaugă în coadă',
toAlbum: 'Vezi albumul',
railRandom: 'Alegere aleatorie',
railHighlyRated: 'Foarte apreciate',
browseTitle: 'Caută toate piesele',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: 'Выбрать другой',
playSong: 'Воспроизвести',
enqueueSong: 'В очередь',
toAlbum: 'К альбому',
railRandom: 'Случайная подборка',
railHighlyRated: 'Высоко оценённые',
browseTitle: 'Просмотреть все треки',
+1
View File
@@ -5,6 +5,7 @@ export const tracks = {
heroReroll: '换一首',
playSong: '播放',
enqueueSong: '加入队列',
toAlbum: '前往专辑',
railRandom: '随机精选',
railHighlyRated: '高分推荐',
browseTitle: '浏览所有曲目',
+22
View File
@@ -24,3 +24,25 @@
color: var(--text-muted);
}
/* ─── Circular cover utility ───
Shared round-cover treatment so circular cover art (single-track cards today,
reusable elsewhere) is defined once instead of re-rolling border-radius:50% per
component. Carries the paint-stability hints round image clipping needs on
WebKitGTK/Wayland — mirrors the artist-card avatar (see artist-card-grid-view.css). */
.cover-circle {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-full);
background: var(--bg-hover);
contain: paint;
isolation: isolate;
transform: translateZ(0);
}
.cover-circle img {
width: 100%;
height: 100%;
object-fit: cover;
}
+40 -8
View File
@@ -16,16 +16,21 @@
}
.song-card-cover {
position: relative;
aspect-ratio: 1;
overflow: hidden;
background: var(--bg-hover);
/* Round/clip/aspect/paint-stability come from .cover-circle (layout/cover-art.css).
Inset margin floats the disc inside the card so corners read as a tile, not an album. */
margin: var(--space-3) var(--space-3) 0;
}
.song-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
/* Vinyl-disc rim — reads as a record edge, sets single tracks apart from album tiles. */
.song-card-cover::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-full);
box-shadow:
inset 0 0 0 2px rgba(0, 0, 0, 0.28),
inset 0 0 0 3px rgba(255, 255, 255, 0.05);
pointer-events: none;
}
.song-card-cover-placeholder {
@@ -82,6 +87,7 @@
.song-card-info {
padding: var(--space-2) var(--space-3) var(--space-3);
text-align: center;
}
.song-card-title {
@@ -103,11 +109,37 @@
text-overflow: ellipsis;
}
.song-card-album-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-hover);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-full);
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast);
}
.song-card-album-badge:hover {
color: var(--accent);
background: var(--bg-card);
border-color: var(--accent);
}
.song-card-rating {
display: flex;
gap: 2px;
color: var(--accent);
margin-top: 4px;
line-height: 1;
justify-content: center;
}