fix: complete sensitive image coverage across all image displays (#488)

* fix: respect sensitive artwork preference in media session

the media session API (CarPlay, lock screen, control center) was
showing unblurred artwork for tracks flagged as sensitive, even when
the user had `show_sensitive_artwork` disabled.

now checks each artwork candidate against the moderation list and
the user's preference before including it in media session metadata.
if all artwork is sensitive and the user prefers not to see it, the
media session will display no artwork.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: apply SensitiveImage wrapper to remaining image displays

extends sensitive content handling to:
- embed page (track artwork)
- album detail page (artwork + og:image meta tags)
- artist page album grid
- track page comment avatars
- search modal results
- handle search/autocomplete avatars

uses compact mode for small avatars (blur only, no tooltip).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

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

authored by zzstoatzz.io Claude and committed by GitHub 29360d58 6a372817

Changed files
+65 -25
frontend
src
lib
routes
embed
track
track
u
[handle]
album
[slug]
+4 -1
frontend/src/lib/components/HandleAutocomplete.svelte
··· 1 1 <script lang="ts"> 2 2 import { API_URL } from '$lib/config'; 3 + import SensitiveImage from './SensitiveImage.svelte'; 3 4 4 5 interface HandleResult { 5 6 did: string; ··· 99 100 onclick={() => selectHandle(result)} 100 101 > 101 102 {#if result.avatar_url} 102 - <img src={result.avatar_url} alt="" class="avatar" /> 103 + <SensitiveImage src={result.avatar_url} compact> 104 + <img src={result.avatar_url} alt="" class="avatar" /> 105 + </SensitiveImage> 103 106 {:else} 104 107 <div class="avatar-placeholder"></div> 105 108 {/if}
+7 -2
frontend/src/lib/components/HandleSearch.svelte
··· 1 1 <script lang="ts"> 2 2 import { API_URL } from '$lib/config'; 3 + import SensitiveImage from './SensitiveImage.svelte'; 3 4 import type { FeaturedArtist } from '$lib/types'; 4 5 5 6 interface Props { ··· 113 114 disabled={selected.some(a => a.did === result.did) || selected.length >= maxFeatures} 114 115 > 115 116 {#if result.avatar_url} 116 - <img src={result.avatar_url} alt={result.display_name} class="result-avatar" /> 117 + <SensitiveImage src={result.avatar_url} compact> 118 + <img src={result.avatar_url} alt={result.display_name} class="result-avatar" /> 119 + </SensitiveImage> 117 120 {/if} 118 121 <div class="result-info"> 119 122 <div class="result-name">{result.display_name}</div> ··· 136 139 {#each selected as artist} 137 140 <div class="selected-artist-chip"> 138 141 {#if artist.avatar_url} 139 - <img src={artist.avatar_url} alt={artist.display_name} class="chip-avatar" /> 142 + <SensitiveImage src={artist.avatar_url} compact> 143 + <img src={artist.avatar_url} alt={artist.display_name} class="chip-avatar" /> 144 + </SensitiveImage> 140 145 {/if} 141 146 <span class="chip-name">{artist.display_name}</span> 142 147 <button
+10 -7
frontend/src/lib/components/SearchModal.svelte
··· 3 3 import { browser } from '$app/environment'; 4 4 import { search, type SearchResult } from '$lib/search.svelte'; 5 5 import { onMount, onDestroy } from 'svelte'; 6 + import SensitiveImage from './SensitiveImage.svelte'; 6 7 7 8 let inputRef: HTMLInputElement | null = $state(null); 8 9 let isMobile = $state(false); ··· 167 168 > 168 169 <span class="result-icon" data-type={result.type}> 169 170 {#if imageUrl} 170 - <img 171 - src={imageUrl} 172 - alt="" 173 - class="result-image" 174 - loading="lazy" 175 - onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')} 176 - /> 171 + <SensitiveImage src={imageUrl} compact> 172 + <img 173 + src={imageUrl} 174 + alt="" 175 + class="result-image" 176 + loading="lazy" 177 + onerror={(e) => ((e.currentTarget as HTMLImageElement).style.display = 'none')} 178 + /> 179 + </SensitiveImage> 177 180 <!-- fallback icon shown if image fails to load --> 178 181 <span class="result-icon-fallback"> 179 182 {#if result.type === 'track'}
+18 -8
frontend/src/lib/components/player/Player.svelte
··· 2 2 import { player } from '$lib/player.svelte'; 3 3 import { queue } from '$lib/queue.svelte'; 4 4 import { nowPlaying } from '$lib/now-playing.svelte'; 5 + import { moderation } from '$lib/moderation.svelte'; 6 + import { preferences } from '$lib/preferences.svelte'; 5 7 import { API_URL } from '$lib/config'; 6 8 import { onMount } from 'svelte'; 7 9 import { page } from '$app/stores'; ··· 9 11 import PlaybackControls from './PlaybackControls.svelte'; 10 12 import type { Track } from '$lib/types'; 11 13 14 + // check if artwork should be shown in media session (respects sensitive content settings) 15 + function shouldShowArtwork(url: string | null | undefined): boolean { 16 + if (!url) return false; 17 + if (!moderation.isSensitive(url)) return true; 18 + return preferences.showSensitiveArtwork; 19 + } 20 + 12 21 // update media session metadata for system media controls (CarPlay, lock screen, etc.) 13 22 function updateMediaSessionMetadata(track: Track) { 14 23 if (!('mediaSession' in navigator)) return; 15 24 25 + // build artwork array, respecting sensitive content settings 16 26 const artwork: MediaImage[] = []; 17 - if (track.image_url) { 18 - artwork.push({ src: track.image_url, sizes: '512x512', type: 'image/jpeg' }); 19 - } else if (track.album?.image_url) { 20 - // fall back to album artwork if no track artwork 21 - artwork.push({ src: track.album.image_url, sizes: '512x512', type: 'image/jpeg' }); 22 - } else if (track.artist_avatar_url) { 23 - // fall back to artist avatar if no album artwork 24 - artwork.push({ src: track.artist_avatar_url, sizes: '256x256', type: 'image/jpeg' }); 27 + if (shouldShowArtwork(track.image_url)) { 28 + artwork.push({ src: track.image_url!, sizes: '512x512', type: 'image/jpeg' }); 29 + } else if (shouldShowArtwork(track.album?.image_url)) { 30 + // fall back to album artwork if no track artwork (or track artwork is sensitive) 31 + artwork.push({ src: track.album!.image_url!, sizes: '512x512', type: 'image/jpeg' }); 32 + } else if (shouldShowArtwork(track.artist_avatar_url)) { 33 + // fall back to artist avatar if no album artwork (or album artwork is sensitive) 34 + artwork.push({ src: track.artist_avatar_url!, sizes: '256x256', type: 'image/jpeg' }); 25 35 } 26 36 27 37 navigator.mediaSession.metadata = new MediaMetadata({
+7 -2
frontend/src/routes/embed/track/[id]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from '$app/stores'; 3 3 import { onMount } from 'svelte'; 4 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 4 5 import type { PageData } from './$types'; 5 6 6 7 let { data }: { data: PageData } = $props(); ··· 47 48 <div class="embed-container"> 48 49 <!-- background image for mobile layout --> 49 50 {#if track.image_url} 50 - <div class="bg-image" style="background-image: url({track.image_url})"></div> 51 + <SensitiveImage src={track.image_url}> 52 + <div class="bg-image" style="background-image: url({track.image_url})"></div> 53 + </SensitiveImage> 51 54 {/if} 52 55 <div class="bg-overlay"></div> 53 56 54 57 <!-- desktop: side art --> 55 58 <div class="art-container"> 56 59 {#if track.image_url} 57 - <img src={track.image_url} alt={track.title} class="art" /> 60 + <SensitiveImage src={track.image_url}> 61 + <img src={track.image_url} alt={track.title} class="art" /> 62 + </SensitiveImage> 58 63 {:else} 59 64 <div class="art-placeholder">♪</div> 60 65 {/if}
+3 -1
frontend/src/routes/track/[id]/+page.svelte
··· 547 547 <div class="comment-content"> 548 548 <div class="comment-header"> 549 549 {#if comment.user_avatar_url} 550 - <img src={comment.user_avatar_url} alt="" class="comment-avatar" /> 550 + <SensitiveImage src={comment.user_avatar_url} compact> 551 + <img src={comment.user_avatar_url} alt="" class="comment-avatar" /> 552 + </SensitiveImage> 551 553 {:else} 552 554 <div class="comment-avatar-placeholder"></div> 553 555 {/if}
+3 -1
frontend/src/routes/u/[handle]/+page.svelte
··· 320 320 <a class="album-card" href="/u/{artist.handle}/album/{album.slug}"> 321 321 <div class="album-cover-wrapper"> 322 322 {#if album.image_url} 323 - <img src={album.image_url} alt="{album.title} artwork" /> 323 + <SensitiveImage src={album.image_url}> 324 + <img src={album.image_url} alt="{album.title} artwork" /> 325 + </SensitiveImage> 324 326 {:else} 325 327 <div class="album-cover-placeholder"> 326 328 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+13 -3
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 3 3 import Header from '$lib/components/Header.svelte'; 4 4 import TrackItem from '$lib/components/TrackItem.svelte'; 5 5 import ShareButton from '$lib/components/ShareButton.svelte'; 6 + import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 7 + import { checkImageSensitive } from '$lib/moderation.svelte'; 6 8 import { player } from '$lib/player.svelte'; 7 9 import { queue } from '$lib/queue.svelte'; 8 10 import { toast } from '$lib/toast.svelte'; ··· 14 16 15 17 const album = $derived(data.album); 16 18 const isAuthenticated = $derived(auth.isAuthenticated); 19 + 20 + // SSR-safe check for sensitive images (for og:image meta tags) 21 + function isImageSensitiveSSR(url: string | null | undefined): boolean { 22 + if (!url) return false; 23 + return checkImageSensitive(url, data.sensitiveImages); 24 + } 17 25 18 26 function playTrack(track: typeof album.tracks[0]) { 19 27 queue.playNow(track); ··· 54 62 <meta property="og:url" content="{APP_CANONICAL_URL}/u/{album.metadata.artist_handle}/album/{album.metadata.slug}" /> 55 63 <meta property="og:site_name" content={APP_NAME} /> 56 64 <meta property="music:musician" content="{album.metadata.artist_handle}" /> 57 - {#if album.metadata.image_url} 65 + {#if album.metadata.image_url && !isImageSensitiveSSR(album.metadata.image_url)} 58 66 <meta property="og:image" content="{album.metadata.image_url}" /> 59 67 <meta property="og:image:secure_url" content="{album.metadata.image_url}" /> 60 68 <meta property="og:image:width" content="1200" /> ··· 66 74 <meta name="twitter:card" content="summary" /> 67 75 <meta name="twitter:title" content="{album.metadata.title} by {album.metadata.artist}" /> 68 76 <meta name="twitter:description" content="{album.metadata.track_count} tracks • {album.metadata.total_plays} plays" /> 69 - {#if album.metadata.image_url} 77 + {#if album.metadata.image_url && !isImageSensitiveSSR(album.metadata.image_url)} 70 78 <meta name="twitter:image" content="{album.metadata.image_url}" /> 71 79 {/if} 72 80 </svelte:head> ··· 76 84 <main> 77 85 <div class="album-hero"> 78 86 {#if album.metadata.image_url} 79 - <img src={album.metadata.image_url} alt="{album.metadata.title} artwork" class="album-art" /> 87 + <SensitiveImage src={album.metadata.image_url} tooltipPosition="center"> 88 + <img src={album.metadata.image_url} alt="{album.metadata.title} artwork" class="album-art" /> 89 + </SensitiveImage> 80 90 {:else} 81 91 <div class="album-art-placeholder"> 82 92 <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">