feat(ux): increase artist album limits and add Others section

- Increase artist detail album fetch from 100 to 500
- Increase bio truncation from 300 to 500 chars
- Add Others category for greatest hits/anthologies
- Add configurable limits for Home sections
- Update HomeSettingsModal with limits grid UI

cp
This commit is contained in:
vicrodh committed 2026-01-15 08:00:16 -06:00
1 parent dd9eb90343
commit bf4050a592
7 files changed
+247 -20

No files matched your search

+2 -2
View File
@@ -167,7 +167,7 @@ pub async fn get_artist(
}
/// Get artist detail with albums, playlists, and appears-on tracks
/// Fetches 100 albums initially to ensure each album type section
/// Fetches 500 albums initially to ensure each album type section
/// (Discography, EPs, Live, etc.) has enough albums loaded
#[tauri::command]
pub async fn get_artist_detail(
@@ -178,7 +178,7 @@ pub async fn get_artist_detail(
let client = state.client.lock().await;
client
.get_artist_detail(artist_id, Some(100), Some(0))
.get_artist_detail(artist_id, Some(500), Some(0))
.await
.map_err(|e| e.to_string())
}
+18
View File
@@ -89,6 +89,12 @@ function isLiveAlbum(album: QobuzAlbum): boolean {
return /\blive\b|\blive at\b|\blive in\b|\ben concert\b|\bin concert\b|\bconcert\b|\bunplugged\b|\bsessions?\b/.test(title);
}
function isOtherAlbum(album: QobuzAlbum): boolean {
const title = album.title?.toLowerCase() ?? '';
return /\b(greatest hits|best of|anthology|collection|box set|essentials|retrospective|very best)\b/.test(title);
}
function toArtistAlbumSummary(album: QobuzAlbum): ArtistAlbumSummary {
const artwork = getQobuzImage(album.image);
const quality = formatQuality(
@@ -199,6 +205,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
const albums: ArtistAlbumSummary[] = [];
const epsSingles: ArtistAlbumSummary[] = [];
const liveAlbums: ArtistAlbumSummary[] = [];
const others: ArtistAlbumSummary[] = [];
for (const album of albumItems) {
const summary = toArtistAlbumSummary(album);
@@ -206,6 +213,8 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
liveAlbums.push(summary);
} else if (isEpOrSingle(album)) {
epsSingles.push(summary);
} else if (isOtherAlbum(album)) {
others.push(summary);
} else {
albums.push(summary);
}
@@ -221,6 +230,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
epsSingles,
liveAlbums,
compilations: buildCompilationAlbums(artist.tracks_appears_on?.items),
others,
playlists: toArtistPlaylists(artist.playlists),
totalAlbums: artist.albums?.total || artist.albums_count || 0,
albumsFetched
@@ -238,10 +248,12 @@ export function appendArtistAlbums(
const existingAlbumIds = new Set(artist.albums.map(album => album.id));
const existingEpIds = new Set(artist.epsSingles.map(album => album.id));
const existingLiveIds = new Set(artist.liveAlbums.map(album => album.id));
const existingOtherIds = new Set(artist.others.map(album => album.id));
const albums = [...artist.albums];
const epsSingles = [...artist.epsSingles];
const liveAlbums = [...artist.liveAlbums];
const others = [...artist.others];
for (const album of newAlbums) {
const summary = toArtistAlbumSummary(album);
@@ -255,6 +267,11 @@ export function appendArtistAlbums(
epsSingles.push(summary);
existingEpIds.add(summary.id);
}
} else if (isOtherAlbum(album)) {
if (!existingOtherIds.has(summary.id)) {
others.push(summary);
existingOtherIds.add(summary.id);
}
} else if (!existingAlbumIds.has(summary.id)) {
albums.push(summary);
existingAlbumIds.add(summary.id);
@@ -266,6 +283,7 @@ export function appendArtistAlbums(
albums,
epsSingles,
liveAlbums,
others,
totalAlbums: totalAlbums ?? artist.totalAlbums,
albumsFetched: albumsFetched ?? artist.albumsFetched + newAlbums.length
};
+128
View File
@@ -11,6 +11,7 @@
setGreetingEnabled,
setCustomGreeting,
resetToDefaults,
updateLimit,
type HomeSettings
} from '$lib/stores/homeSettingsStore';
import { t } from '$lib/i18n';
@@ -57,6 +58,10 @@
function handleReset() {
resetToDefaults();
}
function handleLimitChange(key: keyof HomeSettings['limits'], value: number) {
updateLimit(key, value);
}
</script>
<Modal {isOpen} {onClose} title={$t('home.customizeHome')}>
@@ -98,6 +103,92 @@
{/if}
</div>
<!-- Item Limits -->
<div class="settings-section">
<div class="section-title">Item Limits</div>
<p class="section-desc">Configure how many items to show in each section</p>
<div class="limits-grid">
<div class="limit-item">
<label class="limit-label" for="limit-recent">Recently Played</label>
<input
id="limit-recent"
type="number"
class="limit-input"
min="1"
max="100"
value={settings.limits.recentAlbums}
onchange={(e) => handleLimitChange('recentAlbums', Number(e.currentTarget.value))}
/>
</div>
<div class="limit-item">
<label class="limit-label" for="limit-continue">Continue Listening</label>
<input
id="limit-continue"
type="number"
class="limit-input"
min="1"
max="100"
value={settings.limits.continueTracks}
onchange={(e) => handleLimitChange('continueTracks', Number(e.currentTarget.value))}
/>
</div>
<div class="limit-item">
<label class="limit-label" for="limit-artists">Top Artists</label>
<input
id="limit-artists"
type="number"
class="limit-input"
min="1"
max="100"
value={settings.limits.topArtists}
onchange={(e) => handleLimitChange('topArtists', Number(e.currentTarget.value))}
/>
</div>
<div class="limit-item">
<label class="limit-label" for="limit-fav-albums">Favorite Albums</label>
<input
id="limit-fav-albums"
type="number"
class="limit-input"
min="1"
max="100"
value={settings.limits.favoriteAlbums}
onchange={(e) => handleLimitChange('favoriteAlbums', Number(e.currentTarget.value))}
/>
</div>
<div class="limit-item">
<label class="limit-label" for="limit-fav-tracks">Favorite Tracks</label>
<input
id="limit-fav-tracks"
type="number"
class="limit-input"
min="1"
max="100"
value={settings.limits.favoriteTracks}
onchange={(e) => handleLimitChange('favoriteTracks', Number(e.currentTarget.value))}
/>
</div>
<div class="limit-item">
<label class="limit-label" for="limit-featured">Featured Albums</label>
<input
id="limit-featured"
type="number"
class="limit-input"
min="1"
max="100"
value={settings.limits.featuredAlbums}
onchange={(e) => handleLimitChange('featuredAlbums', Number(e.currentTarget.value))}
/>
</div>
</div>
</div>
<!-- Sections Order -->
<div class="settings-section">
<div class="section-title">{$t('homeSettings.sections')}</div>
@@ -388,4 +479,41 @@
border-color: var(--text-muted);
color: var(--text-primary);
}
.limits-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.limit-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.limit-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.limit-input {
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
transition: all 150ms ease;
}
.limit-input:focus {
outline: none;
border-color: var(--accent);
}
.limit-input:hover {
border-color: var(--text-muted);
}
</style>
@@ -114,6 +114,7 @@
let epsSinglesSection = $state<HTMLDivElement | null>(null);
let liveAlbumsSection = $state<HTMLDivElement | null>(null);
let compilationsSection = $state<HTMLDivElement | null>(null);
let othersSection = $state<HTMLDivElement | null>(null);
let playlistsSection = $state<HTMLDivElement | null>(null);
let activeJumpSection = $state('about');
let jumpObserver: IntersectionObserver | null = null;
@@ -149,7 +150,8 @@
...artist.albums,
...artist.epsSingles,
...artist.liveAlbums,
...artist.compilations
...artist.compilations,
...artist.others
];
loadAllAlbumDownloadStatuses(allAlbums);
}
@@ -185,7 +187,8 @@
...artist.albums,
...artist.epsSingles,
...artist.liveAlbums,
...artist.compilations
...artist.compilations,
...artist.others
];
await loadAllAlbumDownloadStatuses(allAlbums);
}
@@ -339,7 +342,7 @@
// Truncate bio for collapsed view
let truncatedBio = $derived(
bioText && bioText.length > 300 ? bioText.slice(0, 300) + '...' : bioText
bioText && bioText.length > 500 ? bioText.slice(0, 500) + '...' : bioText
);
let hasMoreAlbums = $derived(!!onLoadMore && artist.albumsFetched < artist.totalAlbums);
@@ -347,6 +350,7 @@
let hasEpsSingles = $derived(artist.epsSingles.length > 0);
let hasLiveAlbums = $derived(artist.liveAlbums.length > 0);
let hasCompilations = $derived(artist.compilations.length > 0);
let hasOthers = $derived(artist.others.length > 0);
let hasPlaylists = $derived(artist.playlists.length > 0);
let jumpSections = $derived.by(() => [
{ id: 'about', label: 'About', el: aboutSection, visible: true },
@@ -355,6 +359,7 @@
{ id: 'eps', label: 'EPs & Singles', el: epsSinglesSection, visible: hasEpsSingles },
{ id: 'live', label: 'Live Albums', el: liveAlbumsSection, visible: hasLiveAlbums },
{ id: 'compilations', label: 'Compilations', el: compilationsSection, visible: hasCompilations },
{ id: 'others', label: 'Others', el: othersSection, visible: hasOthers },
{ id: 'playlists', label: 'Playlists', el: playlistsSection, visible: hasPlaylists },
].filter(section => section.visible));
@@ -747,6 +752,36 @@
</div>
{/if}
{#if artist.others.length > 0}
<div class="divider"></div>
<div class="discography section-anchor" bind:this={othersSection}>
<h2 class="section-title">Others</h2>
<div class="albums-grid">
{#each artist.others as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
{downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/>
{/each}
</div>
</div>
{/if}
{#if artist.playlists.length > 0}
<div class="divider"></div>
+17 -14
View File
@@ -107,6 +107,8 @@
featuredAlbums: 12
};
let homeLimits = $state(getSettings().limits);
// Loading states for progressive render
let isInitializing = $state(true);
let isOverlayVisible = $state(true); // Overlay that fades out when ALL sections ready
@@ -215,6 +217,7 @@
// Subscribe to home settings changes
const unsubscribe = subscribeHomeSettings(() => {
homeSettings = getSettings();
homeLimits = getSettings().limits;
});
// Load home data
@@ -341,7 +344,7 @@
return Array.from(counts.entries())
.map(([artistId, playCount]) => ({ artistId, playCount }))
.sort((a, b) => b.playCount - a.playCount)
.slice(0, LIMITS.topArtists);
.slice(0, homeLimits.topArtists);
}
async function loadHome() {
@@ -373,15 +376,15 @@
// Start ML data loading FIRST (local SQLite) - this gets the seeds
const mlPromise = invoke<HomeSeeds>('reco_get_home_ml', {
limitRecentAlbums: LIMITS.recentAlbums,
limitContinueTracks: LIMITS.continueTracks,
limitTopArtists: LIMITS.topArtists,
limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks)
limitRecentAlbums: homeLimits.recentAlbums,
limitContinueTracks: homeLimits.continueTracks,
limitTopArtists: homeLimits.topArtists,
limitFavorites: Math.max(homeLimits.favoriteAlbums, homeLimits.favoriteTracks)
});
// Start Qobuz API calls in parallel (don't await)
if (isSectionVisible('newReleases')) {
fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(async albums => {
fetchFeaturedAlbums('new-releases', homeLimits.featuredAlbums).then(async albums => {
newReleases = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingNewReleases = false;
@@ -392,7 +395,7 @@
}
if (isSectionVisible('pressAwards')) {
fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(async albums => {
fetchFeaturedAlbums('press-awards', homeLimits.featuredAlbums).then(async albums => {
pressAwards = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingPressAwards = false;
@@ -403,7 +406,7 @@
}
if (isSectionVisible('mostStreamed')) {
fetchFeaturedAlbums('most-streamed', LIMITS.featuredAlbums).then(async albums => {
fetchFeaturedAlbums('most-streamed', homeLimits.featuredAlbums).then(async albums => {
mostStreamed = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingMostStreamed = false;
@@ -414,7 +417,7 @@
}
if (isSectionVisible('qobuzissimes')) {
fetchFeaturedAlbums('qobuzissimes', LIMITS.featuredAlbums).then(async albums => {
fetchFeaturedAlbums('qobuzissimes', homeLimits.featuredAlbums).then(async albums => {
qobuzissimes = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingQobuzissimes = false;
@@ -425,7 +428,7 @@
}
if (isSectionVisible('editorPicks')) {
fetchFeaturedAlbums('editor-picks', LIMITS.featuredAlbums).then(async albums => {
fetchFeaturedAlbums('editor-picks', homeLimits.featuredAlbums).then(async albums => {
editorPicks = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingEditorPicks = false;
@@ -454,7 +457,7 @@
// Recently Played (albums) - start immediately with seeds
if (isSectionVisible('recentAlbums')) {
const recentAlbumIds = normalizeAlbumIds(seeds.recentlyPlayedAlbumIds);
fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)).then(async albums => {
fetchAlbums(recentAlbumIds.slice(0, homeLimits.recentAlbums)).then(async albums => {
recentAlbums = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingRecentAlbums = false;
@@ -466,7 +469,7 @@
// Top Artists
if (isSectionVisible('topArtists')) {
fetchArtists(seeds.topArtistIds.slice(0, LIMITS.topArtists)).then(artists => {
fetchArtists(seeds.topArtistIds.slice(0, homeLimits.topArtists)).then(artists => {
topArtists = artists;
loadingTopArtists = false;
markSectionFinished();
@@ -478,12 +481,12 @@
// Favorite Albums
if (isSectionVisible('favoriteAlbums')) {
// Get favorite track details to extract album IDs
fetchTracks(seeds.favoriteTrackIds.slice(0, LIMITS.favoriteTracks)).then(async favoriteTrackDetails => {
fetchTracks(seeds.favoriteTrackIds.slice(0, homeLimits.favoriteTracks)).then(async favoriteTrackDetails => {
const favoriteAlbumIds = normalizeAlbumIds([
...seeds.favoriteAlbumIds,
...favoriteTrackDetails.map(track => track.albumId)
]);
const albums = await fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums));
const albums = await fetchAlbums(favoriteAlbumIds.slice(0, homeLimits.favoriteAlbums));
favoriteAlbums = albums;
await loadAllAlbumDownloadStatuses(albums);
loadingFavoriteAlbums = false;
+37 -1
View File
@@ -22,6 +22,7 @@ export interface HomeSection {
label: string;
visible: boolean;
source?: 'qobuz' | 'ml'; // qobuz = editorial, ml = ML-powered recommendations
limit?: number; // configurable item count
}
export interface HomeSettings {
@@ -30,6 +31,14 @@ export interface HomeSettings {
enabled: boolean;
customText: string | null; // null = use default time-based greeting
};
limits: {
recentAlbums: number;
continueTracks: number;
topArtists: number;
favoriteAlbums: number;
favoriteTracks: number;
featuredAlbums: number;
};
}
const STORAGE_KEY = 'qbz-home-settings';
@@ -52,6 +61,14 @@ const DEFAULT_SETTINGS: HomeSettings = {
greeting: {
enabled: true,
customText: null
},
limits: {
recentAlbums: 20,
continueTracks: 10,
topArtists: 20,
favoriteAlbums: 12,
favoriteTracks: 10,
featuredAlbums: 12
}
};
@@ -88,7 +105,8 @@ function loadSettings(): HomeSettings {
function mergeWithDefaults(saved: Partial<HomeSettings>): HomeSettings {
const result: HomeSettings = {
sections: [],
greeting: saved.greeting ?? DEFAULT_SETTINGS.greeting
greeting: saved.greeting ?? DEFAULT_SETTINGS.greeting,
limits: { ...DEFAULT_SETTINGS.limits, ...(saved.limits || {}) }
};
// Build section map from saved
@@ -304,3 +322,21 @@ export function resetToDefaults(): void {
saveSettings();
notifyListeners();
}
/**
* Update a specific limit
*/
export function updateLimit(key: keyof HomeSettings['limits'], value: number): void {
if (value >= 1 && value <= 100) {
settings.limits[key] = value;
saveSettings();
notifyListeners();
}
}
/**
* Get current limits
*/
export function getLimits(): HomeSettings['limits'] {
return settings.limits;
}
+7
View File
@@ -152,6 +152,13 @@ export interface ArtistDetail {
year?: string;
quality: string;
}[];
others: {
id: string;
title: string;
artwork: string;
year?: string;
quality: string;
}[];
playlists: {
id: number;
title: string;