feat(discovery): unified quality badge on album cards, Qobuz-style layout

The Discovery V2 cards rendered quality as a custom yellow gradient
pill underneath the title — visually disconnected from the badges used
in Mixtape and Collection lists, ugly at small sizes (the Artist
Spotlight section was the worst offender), and not honoring the same
hover tooltip pattern users are used to from the rest of the catalog
surfaces.

QualityBadgeStatic gains an `iconOnly` mode that mirrors Qobuz's web
client: for Hi-Res tier the badge renders the brand SVG bare (no
surrounding chrome — the logo carries its own); for CD / MP3 the
format icon sits centered in a small framed square the same height
as the Hi-Res mark. Hover surfaces the same tier + bitrate tooltip
as the full badge.

AlbumCardLite restructures the post-cover area into a single meta-row:
a two-line text stack (title + artist) on the left with truncation,
the icon-only badge on the right vertically centered across both
lines. The card's parent gap (4px) applies once between the cover and
this row, so the badge inherits the same separation from the album
art that the text already had.

The data adapters in discovery-v2/data.ts now propagate bitDepth and
samplingRate alongside the existing quality string / isHiRes flag so
the badge tooltip reads "Hi-Res: 24-bit / 96 kHz" with real numbers
instead of defaults parsed back out of the quality label.

Popular Albums uses the AlbumRowLite row layout and explicitly drops
the badge — the row already carries the play action and rank cell,
adding a quality mark crowded the line without earning its space.
AlbumRowLite loses its quality / isHiRes props as a cleanup since the
single consumer (DiscoveryView's mostStreamed snippet) no longer
passes them.

Sizing: Hi-Res 46x30, framed 30x30 — both square-ish, both 30px tall,
so they line up cleanly under each other when grids interleave both.

cc
This commit is contained in:
vicrodh committed 2026-05-13 18:41:59 -06:00
1 parent d0349119d0
commit 978d5d681d
6 files changed
+134 -52

No files matched your search

@@ -13,6 +13,13 @@
* min-width, fixed height) so the badge renders as just
* [icon][text-block] for inline use above lists/toolbars. */
bare?: boolean;
/** Render only the icon, mimicking Qobuz's title-row HiRes mark:
* - HiRes → the brand SVG logo alone, no surrounding rectangle.
* - Non-HiRes → the format icon centered in a small framed square
* matching the HiRes logo's outer dimensions.
* Tooltip stays identical to the full badge so hover still surfaces
* the tier label + bitrate string. */
iconOnly?: boolean;
}
let {
@@ -21,6 +28,7 @@
samplingRate,
format,
bare = false,
iconOnly = false,
}: Props = $props();
const tier = $derived.by(() => {
@@ -88,6 +96,21 @@
const isHiRes = $derived(tier === 'max' || tier === 'hires');
</script>
{#if iconOnly}
<div
class="quality-badge icon-only"
class:icon-only-framed={!isHiRes}
title={`${tierLabel}: ${displayText}`}
>
<img
src={iconPath}
alt={tierLabel}
class="badge-icon"
class:hires={isHiRes}
class:icon-only-framed-img={!isHiRes}
/>
</div>
{:else}
<div
class="quality-badge"
class:bare
@@ -107,6 +130,7 @@
<span class="quality-info">{displayText}</span>
</div>
</div>
{/if}
<style>
/* Styles copied 1:1 from QualityBadge.svelte full mode so the two render
@@ -187,4 +211,51 @@
font-weight: 100;
color: #999999;
}
/* Icon-only mode: just the format mark, sized to sit on a title row
next to album / track names. The HiRes brand SVG renders bare (its
own rounded rectangle is part of the artwork); non-HiRes icons sit
inside a small framed square of identical outer dimensions so the
two visual states line up under each other in a grid. */
.quality-badge.icon-only {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
height: auto;
min-width: 0;
flex-shrink: 0;
}
/* Both badges are 32px tall — that's the visual height of two text
lines (title 14px + artist 13px, both line-height 1.3, plus the
2px gap between them). Width differs: the HiRes brand mark keeps
its native ~3:2 aspect (~48px wide so the "Hi·Res / AUDIO" stack
reads cleanly); the non-HiRes framed icon is square (32×32) so a
single icon sits centered in a tidy box. The two badges never
appear side-by-side in the same row, so the different widths
don't compete. */
.quality-badge.icon-only .badge-icon.hires {
width: 46px;
height: 30px;
object-fit: contain;
filter: drop-shadow(0 0 0.4px rgba(0, 0, 0, 0.7));
}
.quality-badge.icon-only-framed {
width: 30px;
height: 30px;
background: var(--alpha-8, rgba(255, 255, 255, 0.06));
border: 1px solid var(--alpha-12, rgba(255, 255, 255, 0.1));
border-radius: 3px;
box-sizing: border-box;
}
.badge-icon.icon-only-framed-img {
width: 16px;
height: 16px;
filter: invert(1) brightness(0.7);
}
</style>
+40 -28
View File
@@ -4,6 +4,7 @@
import { cachedSrc } from '$lib/actions/cachedImage';
import type { AlbumRibbon } from './data';
import AlbumQuickMenu from './AlbumQuickMenu.svelte';
import QualityBadgeStatic from '$lib/components/QualityBadgeStatic.svelte';
interface Props {
albumId: string;
@@ -12,6 +13,11 @@
artwork?: string;
quality?: string;
isHiRes?: boolean;
/** Exact stored values so the quality-badge tooltip is accurate
* ("Hi-Res: 24-bit / 96 kHz" instead of falling back to the
* generic defaults derived from the quality string). */
bitDepth?: number;
samplingRate?: number;
ribbon?: AlbumRibbon;
genre?: string;
releaseYear?: number;
@@ -42,6 +48,8 @@
artwork,
quality,
isHiRes = false,
bitDepth,
samplingRate,
ribbon,
genre,
releaseYear,
@@ -146,15 +154,17 @@
</button>
</div>
</div>
<div class="title">{title}</div>
{#if onArtistClick}
<button class="artist-link" type="button" onclick={handleArtist}>{artist}</button>
{:else}
<div class="artist">{artist}</div>
{/if}
{#if isHiRes && quality}
<div class="quality quality-hires" title={quality}>Hi-Res</div>
{/if}
<div class="meta-row">
<div class="text-stack">
<div class="title">{title}</div>
{#if onArtistClick}
<button class="artist-link" type="button" onclick={handleArtist}>{artist}</button>
{:else}
<div class="artist">{artist}</div>
{/if}
</div>
<QualityBadgeStatic iconOnly {quality} {bitDepth} {samplingRate} />
</div>
</div>
<AlbumQuickMenu
@@ -349,6 +359,27 @@
border-color: var(--accent-primary);
}
/* Meta row hosts the two-line text stack (title + artist) on the
left and the quality badge on the right. The badge is centered
vertically across both lines, matching the height of the combined
text stack. The card's parent gap (4px) applies once between the
cover and this row, so the badge inherits the same separation
from the album art that the text does. */
.meta-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.text-stack {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.title {
font-size: 14px;
font-weight: 500;
@@ -422,23 +453,4 @@
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.15);
}
/* Hi-Res badge. Only renders when isHiRes; CD-quality albums leave the
space empty, matching the Qobuz web pattern (no badge clutter). The
full quality string ("24bit/96kHz") is exposed via title attribute. */
.quality-hires {
margin-top: 4px;
font-family: 'LINE Seed JP', var(--font-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: #1f1407;
background: linear-gradient(135deg, #f5c042 0%, #d49511 100%);
border-radius: 3px;
padding: 3px 6px;
align-self: flex-start;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
-20
View File
@@ -9,8 +9,6 @@
title: string;
artist: string;
artwork?: string;
quality?: string;
isHiRes?: boolean;
/** 1-based rank shown on the left at rest; swaps to a play button on hover. */
rank?: number;
onClick?: () => void;
@@ -29,8 +27,6 @@
title,
artist,
artwork,
quality,
isHiRes = false,
rank,
onClick,
onArtistClick,
@@ -112,9 +108,6 @@
<div class="artist">{artist}</div>
{/if}
</div>
{#if isHiRes && quality}
<div class="quality quality-hires" title={quality}>Hi-Res</div>
{/if}
<button
class="menu-btn"
type="button"
@@ -256,19 +249,6 @@
font-family: inherit;
}
.quality-hires {
flex: 0 0 auto;
font-family: 'LINE Seed JP', var(--font-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: #1f1407;
background: linear-gradient(135deg, #f5c042 0%, #d49511 100%);
border-radius: 3px;
padding: 2px 6px;
white-space: nowrap;
}
.menu-btn {
flex: 0 0 28px;
width: 28px;
+3 -1
View File
@@ -405,6 +405,9 @@
artist={album.artist}
artwork={album.artwork}
quality={album.quality}
isHiRes={album.isHiRes}
bitDepth={album.bitDepth}
samplingRate={album.samplingRate}
ribbon={album.ribbon}
genre={album.genre}
releaseYear={album.releaseYear}
@@ -430,7 +433,6 @@
title={album.title}
artist={album.artist}
artwork={album.artwork}
quality={album.quality}
rank={index + 1}
onClick={() => onAlbumClick?.(album.albumId)}
onPlay={() => onAlbumPlay?.(album.albumId)}
@@ -219,6 +219,8 @@
artwork={item.album.artwork}
quality={item.album.quality}
isHiRes={item.album.isHiRes}
bitDepth={item.album.bitDepth}
samplingRate={item.album.samplingRate}
ribbon={item.album.ribbon}
genre={item.album.genre}
releaseYear={item.album.releaseYear}
+18 -3
View File
@@ -44,10 +44,15 @@ export interface DiscoveryAlbumCard {
artistId?: number;
artwork?: string;
quality?: string;
/** True when the album is > 16-bit; gates the yellow Hi-Res badge. The
* Qobuz web player only renders the badge for Hi-Res albums and leaves
* CD-quality ones uncluttered. */
/** True when the album is > 16-bit. Drives the title-row badge: HiRes
* shows the Qobuz brand logo bare; non-HiRes shows the format icon in
* a small framed square of matching dimensions. */
isHiRes?: boolean;
/** Exact stored bit depth, fed to QualityBadgeStatic so the hover
* tooltip ("Hi-Res: 24-bit / 96 kHz") matches reality instead of
* falling back to the parser's defaults from the quality string. */
bitDepth?: number;
samplingRate?: number;
ribbon?: AlbumRibbon;
genre?: string;
releaseYear?: number;
@@ -95,6 +100,8 @@ function qobuzAlbumToCard(album: QobuzAlbum): DiscoveryAlbumCard {
artwork: getQobuzImageForSize(album.image, 'small'),
quality: formatQuality(hires, album.maximum_bit_depth, album.maximum_sampling_rate),
isHiRes: hires,
bitDepth: album.maximum_bit_depth,
samplingRate: album.maximum_sampling_rate,
ribbon: pickAlbumRibbon(album.awards),
genre: album.genre?.name,
releaseYear: parseYear(album.release_date_original),
@@ -115,6 +122,8 @@ function discoverAlbumToCard(album: DiscoverAlbum): DiscoveryAlbumCard {
album.audio_info?.maximum_sampling_rate
),
isHiRes: hires,
bitDepth: album.audio_info?.maximum_bit_depth,
samplingRate: album.audio_info?.maximum_sampling_rate,
ribbon: pickAlbumRibbon(album.awards),
genre: album.genre?.name,
releaseYear: parseYear(album.dates?.original),
@@ -421,6 +430,8 @@ export async function fetchSimilarAlbums(
artwork: a.image?.small || a.image?.large || a.image?.thumbnail,
quality: formatQuality(a.hires, a.maximum_bit_depth, a.maximum_sampling_rate),
isHiRes: a.hires,
bitDepth: a.maximum_bit_depth,
samplingRate: a.maximum_sampling_rate,
genre: a.genre?.name,
releaseYear: parseYear(a.release_date_original),
})),
@@ -471,6 +482,8 @@ export async function fetchEssentialsByGenre(
artwork: a.image?.small || a.image?.large || a.image?.thumbnail,
quality: formatQuality(a.hires, a.maximum_bit_depth, a.maximum_sampling_rate),
isHiRes: a.hires,
bitDepth: a.maximum_bit_depth,
samplingRate: a.maximum_sampling_rate,
genre: a.genre?.name,
releaseYear: parseYear(a.release_date_original),
})),
@@ -580,6 +593,8 @@ export async function fetchArtistSpotlight(
rel.audio_info?.maximum_sampling_rate
),
isHiRes: hires,
bitDepth: rel.audio_info?.maximum_bit_depth,
samplingRate: rel.audio_info?.maximum_sampling_rate,
genre: rel.genre?.name,
releaseYear: parseYear(rel.dates?.original),
});