mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
committed by
GitHub
parent
47832632fd
commit
cc04a0c93d
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const tracks = {
|
||||
heroReroll: 'Выбрать другой',
|
||||
playSong: 'Воспроизвести',
|
||||
enqueueSong: 'В очередь',
|
||||
toAlbum: 'К альбому',
|
||||
railRandom: 'Случайная подборка',
|
||||
railHighlyRated: 'Высоко оценённые',
|
||||
browseTitle: 'Просмотреть все треки',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const tracks = {
|
||||
heroReroll: '换一首',
|
||||
playSong: '播放',
|
||||
enqueueSong: '加入队列',
|
||||
toAlbum: '前往专辑',
|
||||
railRandom: '随机精选',
|
||||
railHighlyRated: '高分推荐',
|
||||
browseTitle: '浏览所有曲目',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user