fix(ux): favorite button click + home loading overlay

TrackRow:
- Convert favorite indicator div to interactive button
- Add stopPropagation to prevent row click (play) when clicking favorite
- Add hover state and proper button styling

HomeView:
- Add loading overlay that waits for at least 2 sections to load
- Overlay fades out smoothly when content is ready
- Prevents jarring "sections appearing and reordering" effect
This commit is contained in:
vicrodh committed 2026-01-11 01:55:09 -06:00
1 parent 0ca7d50615
commit c529a28c6e
2 files changed
+126 -13

No files matched your search

+28 -9
View File
@@ -97,14 +97,22 @@
<div class="track-quality">{quality}</div> <div class="track-quality">{quality}</div>
{/if} {/if}
<!-- Favorite Indicator --> <!-- Favorite Button -->
<div class="favorite-indicator" class:is-favorite={isFavorite}> <button
class="favorite-btn"
class:is-favorite={isFavorite}
onclick={(e) => {
e.stopPropagation();
menuActions?.onAddFavorite?.();
}}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
{#if isFavorite} {#if isFavorite}
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" /> <Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else} {:else}
<Heart size={14} color="var(--text-muted)" /> <Heart size={14} color="var(--text-muted)" />
{/if} {/if}
</div> </button>
<!-- Download Indicator --> <!-- Download Indicator -->
{#if !hideDownload} {#if !hideDownload}
@@ -251,24 +259,35 @@
text-align: right; text-align: right;
} }
.favorite-indicator { .favorite-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 24px; width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0.3; opacity: 0.3;
transition: opacity 150ms ease; transition: opacity 150ms ease, background-color 150ms ease;
} }
.favorite-indicator.is-favorite { .favorite-btn.is-favorite {
opacity: 1; opacity: 1;
} }
.track-row:hover .favorite-indicator { .favorite-btn:hover {
opacity: 1;
background-color: var(--bg-tertiary);
}
.track-row:hover .favorite-btn {
opacity: 0.6; opacity: 0.6;
} }
.track-row:hover .favorite-indicator.is-favorite { .track-row:hover .favorite-btn.is-favorite,
.track-row:hover .favorite-btn:hover {
opacity: 1; opacity: 1;
} }
+98 -4
View File
@@ -83,6 +83,7 @@
// Loading states for progressive render // Loading states for progressive render
let isInitializing = $state(true); let isInitializing = $state(true);
let isOverlayVisible = $state(true); // Overlay that fades out when ready
let error = $state<string | null>(null); let error = $state<string | null>(null);
let loadingNewReleases = $state(true); let loadingNewReleases = $state(true);
let loadingPressAwards = $state(true); let loadingPressAwards = $state(true);
@@ -94,6 +95,10 @@
let loadingTopArtists = $state(true); let loadingTopArtists = $state(true);
let loadingFavoriteAlbums = $state(true); let loadingFavoriteAlbums = $state(true);
// Track when we have enough content to show
let sectionsReady = $state(0);
const MIN_SECTIONS_BEFORE_SHOW = 2; // Wait for at least 2 sections
// Featured albums (from Qobuz editorial) // Featured albums (from Qobuz editorial)
let newReleases = $state<AlbumCardData[]>([]); let newReleases = $state<AlbumCardData[]>([]);
let pressAwards = $state<AlbumCardData[]>([]); let pressAwards = $state<AlbumCardData[]>([]);
@@ -121,6 +126,17 @@
|| favoriteAlbums.length > 0 || favoriteAlbums.length > 0
); );
// Mark a section as ready and check if we can hide overlay
function markSectionReady() {
sectionsReady++;
if (sectionsReady >= MIN_SECTIONS_BEFORE_SHOW && isOverlayVisible) {
// Small delay to ensure DOM is ready, then fade out
setTimeout(() => {
isOverlayVisible = false;
}, 100);
}
}
onMount(() => { onMount(() => {
// Subscribe to home settings changes // Subscribe to home settings changes
const unsubscribe = subscribeHomeSettings(() => { const unsubscribe = subscribeHomeSettings(() => {
@@ -256,6 +272,8 @@
async function loadHome() { async function loadHome() {
isInitializing = true; isInitializing = true;
isOverlayVisible = true;
sectionsReady = 0;
error = null; error = null;
loadingNewReleases = true; loadingNewReleases = true;
loadingPressAwards = true; loadingPressAwards = true;
@@ -274,8 +292,7 @@
fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(albums => { fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(albums => {
newReleases = albums; newReleases = albums;
loadingNewReleases = false; loadingNewReleases = false;
// Hide initializing as soon as we have some content if (albums.length > 0) markSectionReady();
if (albums.length > 0) isInitializing = false;
}); });
} else { } else {
loadingNewReleases = false; loadingNewReleases = false;
@@ -285,6 +302,7 @@
fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(albums => { fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(albums => {
pressAwards = albums; pressAwards = albums;
loadingPressAwards = false; loadingPressAwards = false;
if (albums.length > 0) markSectionReady();
}); });
} else { } else {
loadingPressAwards = false; loadingPressAwards = false;
@@ -294,6 +312,7 @@
fetchFeaturedAlbums('most-streamed', LIMITS.featuredAlbums).then(albums => { fetchFeaturedAlbums('most-streamed', LIMITS.featuredAlbums).then(albums => {
mostStreamed = albums; mostStreamed = albums;
loadingMostStreamed = false; loadingMostStreamed = false;
if (albums.length > 0) markSectionReady();
}); });
} else { } else {
loadingMostStreamed = false; loadingMostStreamed = false;
@@ -303,6 +322,7 @@
fetchFeaturedAlbums('qobuzissimes', LIMITS.featuredAlbums).then(albums => { fetchFeaturedAlbums('qobuzissimes', LIMITS.featuredAlbums).then(albums => {
qobuzissimes = albums; qobuzissimes = albums;
loadingQobuzissimes = false; loadingQobuzissimes = false;
if (albums.length > 0) markSectionReady();
}); });
} else { } else {
loadingQobuzissimes = false; loadingQobuzissimes = false;
@@ -312,6 +332,7 @@
fetchFeaturedAlbums('editor-picks', LIMITS.featuredAlbums).then(albums => { fetchFeaturedAlbums('editor-picks', LIMITS.featuredAlbums).then(albums => {
editorPicks = albums; editorPicks = albums;
loadingEditorPicks = false; loadingEditorPicks = false;
if (albums.length > 0) markSectionReady();
}); });
} else { } else {
loadingEditorPicks = false; loadingEditorPicks = false;
@@ -326,13 +347,14 @@
limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks) limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks)
}); });
// Seeds are ready, hide initializing state - show sections with loading // Seeds are ready, can start showing content
isInitializing = false; isInitializing = false;
// Load each section in parallel for progressive rendering // Load each section in parallel for progressive rendering
const continueTracksPromise = fetchTracks(seeds.continueListeningTrackIds).then(tracks => { const continueTracksPromise = fetchTracks(seeds.continueListeningTrackIds).then(tracks => {
continueTracks = tracks; continueTracks = tracks;
loadingContinueTracks = false; loadingContinueTracks = false;
if (tracks.length > 0) markSectionReady();
return tracks; return tracks;
}); });
@@ -347,6 +369,7 @@
fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)).then(albums => { fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)).then(albums => {
recentAlbums = albums; recentAlbums = albums;
loadingRecentAlbums = false; loadingRecentAlbums = false;
if (albums.length > 0) markSectionReady();
}); });
// Favorite albums // Favorite albums
@@ -358,6 +381,7 @@
const albums = await fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums)); const albums = await fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums));
favoriteAlbums = albums; favoriteAlbums = albums;
loadingFavoriteAlbums = false; loadingFavoriteAlbums = false;
if (albums.length > 0) markSectionReady();
}); });
// Top artists // Top artists
@@ -369,12 +393,14 @@
fetchArtists(artistSeeds.slice(0, LIMITS.topArtists)).then(artists => { fetchArtists(artistSeeds.slice(0, LIMITS.topArtists)).then(artists => {
topArtists = artists; topArtists = artists;
loadingTopArtists = false; loadingTopArtists = false;
if (artists.length > 0) markSectionReady();
}); });
} catch (err) { } catch (err) {
console.error('Failed to load home data:', err); console.error('Failed to load home data:', err);
error = String(err); error = String(err);
isInitializing = false; isInitializing = false;
isOverlayVisible = false;
loadingRecentAlbums = false; loadingRecentAlbums = false;
loadingContinueTracks = false; loadingContinueTracks = false;
loadingTopArtists = false; loadingTopArtists = false;
@@ -384,6 +410,19 @@
</script> </script>
<div class="home-view"> <div class="home-view">
<!-- Loading Overlay - fades out when content is ready -->
{#if isOverlayVisible}
<div class="loading-overlay" class:fade-out={!isInitializing && sectionsReady >= MIN_SECTIONS_BEFORE_SHOW}>
<div class="loading-content">
<div class="loading-icon">
<Loader2 size={36} class="spinner" />
</div>
<h2>Loading your home</h2>
<p>Building recommendations from your listening history.</p>
</div>
</div>
{/if}
<!-- Header with greeting and settings --> <!-- Header with greeting and settings -->
<div class="home-header"> <div class="home-header">
{#if homeSettings.greeting.enabled} {#if homeSettings.greeting.enabled}
@@ -396,7 +435,7 @@
</button> </button>
</div> </div>
{#if isInitializing} {#if isInitializing && !isOverlayVisible}
<div class="home-state"> <div class="home-state">
<div class="state-icon loading"> <div class="state-icon loading">
<Loader2 size={36} class="spinner" /> <Loader2 size={36} class="spinner" />
@@ -604,6 +643,61 @@
.home-view { .home-view {
width: 100%; width: 100%;
padding-bottom: 24px; padding-bottom: 24px;
position: relative;
}
/* Loading Overlay */
.loading-overlay {
position: absolute;
inset: 0;
z-index: 10;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 300ms ease-out;
}
.loading-overlay.fade-out {
opacity: 0;
pointer-events: none;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.loading-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary, #6366f1) 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.loading-content h2 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.loading-content p {
font-size: 15px;
color: var(--text-muted);
margin: 0;
max-width: 360px;
}
.loading-icon :global(.spinner) {
animation: spin 1s linear infinite;
} }
.home-header { .home-header {