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:
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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user