diff --git a/CHANGELOG.md b/CHANGELOG.md index 43432bae..936790fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx index fde7c9bf..d9363bca 100644 --- a/src/components/SongCard.tsx +++ b/src/components/SongCard.tsx @@ -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 (
-
+
{!disableArtwork && coverRef ? ( {song.artist}

+ {song.albumId && ( + + )} {(song.userRating ?? 0) > 0 && (
{Array.from({ length: 5 }, (_, i) => ( diff --git a/src/locales/de/tracks.ts b/src/locales/de/tracks.ts index 54d5fd7b..2994c7ea 100644 --- a/src/locales/de/tracks.ts +++ b/src/locales/de/tracks.ts @@ -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', diff --git a/src/locales/en/tracks.ts b/src/locales/en/tracks.ts index 7b6e0a88..fcbfe300 100644 --- a/src/locales/en/tracks.ts +++ b/src/locales/en/tracks.ts @@ -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', diff --git a/src/locales/es/tracks.ts b/src/locales/es/tracks.ts index 5fe4d6c2..6fdbd5f7 100644 --- a/src/locales/es/tracks.ts +++ b/src/locales/es/tracks.ts @@ -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', diff --git a/src/locales/fr/tracks.ts b/src/locales/fr/tracks.ts index f706422e..64cc7c97 100644 --- a/src/locales/fr/tracks.ts +++ b/src/locales/fr/tracks.ts @@ -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', diff --git a/src/locales/nb/tracks.ts b/src/locales/nb/tracks.ts index 37d0e25a..d1948fcd 100644 --- a/src/locales/nb/tracks.ts +++ b/src/locales/nb/tracks.ts @@ -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', diff --git a/src/locales/nl/tracks.ts b/src/locales/nl/tracks.ts index 6096b51e..589827f8 100644 --- a/src/locales/nl/tracks.ts +++ b/src/locales/nl/tracks.ts @@ -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', diff --git a/src/locales/ro/tracks.ts b/src/locales/ro/tracks.ts index 250896b1..9f7ae17e 100644 --- a/src/locales/ro/tracks.ts +++ b/src/locales/ro/tracks.ts @@ -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', diff --git a/src/locales/ru/tracks.ts b/src/locales/ru/tracks.ts index 4114f101..d198648b 100644 --- a/src/locales/ru/tracks.ts +++ b/src/locales/ru/tracks.ts @@ -5,6 +5,7 @@ export const tracks = { heroReroll: 'Выбрать другой', playSong: 'Воспроизвести', enqueueSong: 'В очередь', + toAlbum: 'К альбому', railRandom: 'Случайная подборка', railHighlyRated: 'Высоко оценённые', browseTitle: 'Просмотреть все треки', diff --git a/src/locales/zh/tracks.ts b/src/locales/zh/tracks.ts index 6d7e3662..bb96e30c 100644 --- a/src/locales/zh/tracks.ts +++ b/src/locales/zh/tracks.ts @@ -5,6 +5,7 @@ export const tracks = { heroReroll: '换一首', playSong: '播放', enqueueSong: '加入队列', + toAlbum: '前往专辑', railRandom: '随机精选', railHighlyRated: '高分推荐', browseTitle: '浏览所有曲目', diff --git a/src/styles/layout/cover-art.css b/src/styles/layout/cover-art.css index a1c2ef82..315c30db 100644 --- a/src/styles/layout/cover-art.css +++ b/src/styles/layout/cover-art.css @@ -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; +} + diff --git a/src/styles/tracks/song-card.css b/src/styles/tracks/song-card.css index b879a4ce..90827b27 100644 --- a/src/styles/tracks/song-card.css +++ b/src/styles/tracks/song-card.css @@ -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; }