audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: add fade transitions and reduced-motion support across loading states (#972)

Matches the gold standard from the artist detail page analytics section:
shimmer→content transitions now fade smoothly instead of hard-cutting.
WaveLoading respects prefers-reduced-motion.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
749d5547 cd75e8c7

+123 -105
+38 -35
frontend/src/lib/components/CommentersTooltip.svelte
··· 6 6 triggerAvatarRefresh, 7 7 hasAttemptedRefresh 8 8 } from '$lib/avatar-refresh.svelte'; 9 + import { fade } from 'svelte/transition'; 9 10 import SensitiveImage from './SensitiveImage.svelte'; 10 11 11 12 interface Props { ··· 126 127 onmouseenter={onMouseEnter} 127 128 onmouseleave={onMouseLeave} 128 129 > 129 - {#if loading} 130 - <div class="loading"> 131 - <div class="loading-avatars"> 132 - {#each [1, 2, 3] as _} 133 - <div class="avatar-skeleton"></div> 130 + {#key loading} 131 + {#if loading} 132 + <div class="loading" transition:fade={{ duration: 200 }}> 133 + <div class="loading-avatars"> 134 + {#each [1, 2, 3] as _} 135 + <div class="avatar-skeleton"></div> 136 + {/each} 137 + </div> 138 + </div> 139 + {:else if error} 140 + <div class="error" transition:fade={{ duration: 200 }}>{error}</div> 141 + {:else if commenters.length > 0} 142 + <div class="commenters-avatars" transition:fade={{ duration: 200 }}> 143 + {#each commenters as commenter (commenter.did)} 144 + {@const displayUrl = getDisplayUrl(commenter)} 145 + {@const showFallback = shouldShowFallback(commenter)} 146 + <a 147 + href="/u/{commenter.handle}" 148 + class="commenter-circle" 149 + title="{commenter.display_name || commenter.handle} (@{commenter.handle})" 150 + > 151 + {#if displayUrl && !showFallback} 152 + <SensitiveImage src={displayUrl} compact> 153 + <img 154 + src={displayUrl} 155 + alt="" 156 + onerror={() => handleAvatarError(commenter.did)} 157 + /> 158 + </SensitiveImage> 159 + {:else} 160 + <span>{(commenter.display_name || commenter.handle).charAt(0).toUpperCase()}</span> 161 + {/if} 162 + </a> 134 163 {/each} 135 164 </div> 136 - </div> 137 - {:else if error} 138 - <div class="error">{error}</div> 139 - {:else if commenters.length > 0} 140 - <div class="commenters-avatars"> 141 - {#each commenters as commenter (commenter.did)} 142 - {@const displayUrl = getDisplayUrl(commenter)} 143 - {@const showFallback = shouldShowFallback(commenter)} 144 - <a 145 - href="/u/{commenter.handle}" 146 - class="commenter-circle" 147 - title="{commenter.display_name || commenter.handle} (@{commenter.handle})" 148 - > 149 - {#if displayUrl && !showFallback} 150 - <SensitiveImage src={displayUrl} compact> 151 - <img 152 - src={displayUrl} 153 - alt="" 154 - onerror={() => handleAvatarError(commenter.did)} 155 - /> 156 - </SensitiveImage> 157 - {:else} 158 - <span>{(commenter.display_name || commenter.handle).charAt(0).toUpperCase()}</span> 159 - {/if} 160 - </a> 161 - {/each} 162 - </div> 163 - {:else} 164 - <div class="empty">no comments yet</div> 165 - {/if} 165 + {:else} 166 + <div class="empty" transition:fade={{ duration: 200 }}>no comments yet</div> 167 + {/if} 168 + {/key} 166 169 </div> 167 170 168 171 <style>
+38 -35
frontend/src/lib/components/LikersTooltip.svelte
··· 6 6 triggerAvatarRefresh, 7 7 hasAttemptedRefresh 8 8 } from '$lib/avatar-refresh.svelte'; 9 + import { fade } from 'svelte/transition'; 9 10 import SensitiveImage from './SensitiveImage.svelte'; 10 11 11 12 interface Props { ··· 142 143 onmouseenter={onMouseEnter} 143 144 onmouseleave={onMouseLeave} 144 145 > 145 - {#if loading} 146 - <div class="loading"> 147 - <div class="loading-avatars"> 148 - {#each [1, 2, 3] as _} 149 - <div class="avatar-skeleton"></div> 146 + {#key loading} 147 + {#if loading} 148 + <div class="loading" transition:fade={{ duration: 200 }}> 149 + <div class="loading-avatars"> 150 + {#each [1, 2, 3] as _} 151 + <div class="avatar-skeleton"></div> 152 + {/each} 153 + </div> 154 + </div> 155 + {:else if error} 156 + <div class="error" transition:fade={{ duration: 200 }}>{error}</div> 157 + {:else if likers.length > 0} 158 + <div class="likers-avatars" transition:fade={{ duration: 200 }}> 159 + {#each likers as liker (liker.did)} 160 + {@const displayUrl = getDisplayUrl(liker)} 161 + {@const showFallback = shouldShowFallback(liker)} 162 + <a 163 + href="/u/{liker.handle}/liked" 164 + class="liker-circle" 165 + title="{liker.display_name} (@{liker.handle}) • {formatTime(liker.liked_at)}" 166 + > 167 + {#if displayUrl && !showFallback} 168 + <SensitiveImage src={displayUrl} compact> 169 + <img 170 + src={displayUrl} 171 + alt="" 172 + onerror={() => handleAvatarError(liker.did)} 173 + /> 174 + </SensitiveImage> 175 + {:else} 176 + <span>{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 177 + {/if} 178 + </a> 150 179 {/each} 151 180 </div> 152 - </div> 153 - {:else if error} 154 - <div class="error">{error}</div> 155 - {:else if likers.length > 0} 156 - <div class="likers-avatars"> 157 - {#each likers as liker (liker.did)} 158 - {@const displayUrl = getDisplayUrl(liker)} 159 - {@const showFallback = shouldShowFallback(liker)} 160 - <a 161 - href="/u/{liker.handle}/liked" 162 - class="liker-circle" 163 - title="{liker.display_name} (@{liker.handle}) • {formatTime(liker.liked_at)}" 164 - > 165 - {#if displayUrl && !showFallback} 166 - <SensitiveImage src={displayUrl} compact> 167 - <img 168 - src={displayUrl} 169 - alt="" 170 - onerror={() => handleAvatarError(liker.did)} 171 - /> 172 - </SensitiveImage> 173 - {:else} 174 - <span>{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 175 - {/if} 176 - </a> 177 - {/each} 178 - </div> 179 - {:else} 180 - <div class="empty">be the first to like this</div> 181 - {/if} 181 + {:else} 182 + <div class="empty" transition:fade={{ duration: 200 }}>be the first to like this</div> 183 + {/if} 184 + {/key} 182 185 </div> 183 186 184 187 <style>
+11 -8
frontend/src/lib/components/PlatformStats.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 + import { fade } from 'svelte/transition'; 3 4 import { statsCache, formatDuration } from '$lib/stats.svelte'; 4 5 5 6 interface Props { ··· 73 74 </svg> 74 75 <span>stats</span> 75 76 </div> 76 - {#if loading} 77 - <div class="stats-menu-loading"> 78 - <span class="skeleton-text"></span> 79 - </div> 80 - {:else if stats} 81 - <div class="stats-menu-grid"> 77 + {#key loading} 78 + {#if loading} 79 + <div class="stats-menu-loading" transition:fade={{ duration: 200 }}> 80 + <span class="skeleton-text"></span> 81 + </div> 82 + {:else if stats} 83 + <div class="stats-menu-grid" transition:fade={{ duration: 200 }}> 82 84 <div class="stats-menu-item"> 83 85 <svg class="menu-stat-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 84 86 <polygon points="5 3 19 12 5 21 5 3"></polygon> ··· 111 113 <span class="stats-menu-value">{formatDuration(stats.total_duration_seconds)}</span> 112 114 <span class="stats-menu-label">of audio</span> 113 115 </div> 114 - </div> 115 - {/if} 116 + </div> 117 + {/if} 118 + {/key} 116 119 </div> 117 120 {/if} 118 121
+6
frontend/src/lib/components/WaveLoading.svelte
··· 70 70 font-size: var(--text-base); 71 71 text-align: center; 72 72 } 73 + 74 + @media (prefers-reduced-motion: reduce) { 75 + .bar { 76 + animation: none; 77 + } 78 + } 73 79 </style>
+30 -27
frontend/src/routes/+page.svelte
··· 11 11 import { tracksCache, fetchTopTracks } from '$lib/tracks.svelte'; 12 12 import type { Track } from '$lib/types'; 13 13 import { auth } from '$lib/auth.svelte'; 14 + import { fade } from 'svelte/transition'; 14 15 import { APP_NAME, APP_TAGLINE, APP_CANONICAL_URL } from '$lib/branding'; 15 16 import { API_URL } from '$lib/config'; 16 17 import { ··· 139 140 140 141 <main> 141 142 <!-- top tracks section --> 142 - {#if loadingTopTracks} 143 - <section class="top-tracks"> 144 - <h2> 145 - top tracks 146 - </h2> 147 - <div class="loading-container compact"> 148 - <WaveLoading size="sm" message="loading..." /> 149 - </div> 150 - </section> 151 - {:else if hasTopTracks} 152 - <section class="top-tracks"> 153 - <h2> 154 - top tracks 155 - </h2> 156 - <div class="top-tracks-grid"> 157 - {#each topTracks as track, i} 158 - <TrackCard 159 - {track} 160 - index={i} 161 - isPlaying={player.currentTrack?.id === track.id} 162 - onPlay={(t) => queue.playNow(t)} 163 - /> 164 - {/each} 165 - </div> 166 - </section> 167 - {/if} 143 + {#key loadingTopTracks} 144 + {#if loadingTopTracks} 145 + <section class="top-tracks" transition:fade={{ duration: 200 }}> 146 + <h2> 147 + top tracks 148 + </h2> 149 + <div class="loading-container compact"> 150 + <WaveLoading size="sm" message="loading..." /> 151 + </div> 152 + </section> 153 + {:else if hasTopTracks} 154 + <section class="top-tracks" transition:fade={{ duration: 200 }}> 155 + <h2> 156 + top tracks 157 + </h2> 158 + <div class="top-tracks-grid"> 159 + {#each topTracks as track, i} 160 + <TrackCard 161 + {track} 162 + index={i} 163 + isPlaying={player.currentTrack?.id === track.id} 164 + onPlay={(t) => queue.playNow(t)} 165 + /> 166 + {/each} 167 + </div> 168 + </section> 169 + {/if} 170 + {/key} 168 171 169 172 <!-- artists from your bluesky network --> 170 173 {#if hasNetworkArtists} 171 - <section class="network-artists"> 174 + <section class="network-artists" transition:fade={{ duration: 200 }}> 172 175 <h2>artists you know</h2> 173 176 <div class="artist-grid"> 174 177 {#each networkArtists as artist}