feat: glass effects and custom background images (#595)

* feat: add glass effects and track item styling

- add CSS variables for glass effects (--glass-bg, --glass-blur, --glass-border)
- apply backdrop-filter blur to Player, Header, and Queue sidebar
- add translucent backgrounds to TrackItem without blur (performance safe)
- add subtle border-radius (6px) and box-shadow to track items
- support both dark and light themes with appropriate glass values
- remove conflicting light theme overrides in favor of CSS variables

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

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

* feat: add ui_settings JSONB column for extensible preferences

- add ui_settings JSONB column to user_preferences table
- update preferences API to expose ui_settings field
- merge ui_settings on partial updates to support incremental changes
- add migration for new column
- add tests for ui_settings CRUD and persistence

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

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

* feat: add background image settings UI

- add UiSettings interface with background_image_url and background_tile
- add background image URL input and tile toggle in settings page
- apply background image via CSS custom properties in layout
- update preferences manager with updateUiSettings method

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

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

* feat: refine track item hover behavior and glass styling

- remove chunky left border, use uniform subtle border
- add tactile hover: 0.5px lift with accent-tinted glow
- smooth cubic-bezier easing for polished feel
- active state settles back down on click
- adjust track background opacity (88%) for better balance
- fix background image input reactivity bug

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

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

* feat: add subtle 3D wheel scroll effect to track items

tracks now appear on a convex cylinder surface:
- items at viewport center are closest
- items above/below rotate away slightly (2° max)
- uses passive scroll listener for performance
- transform-style: preserve-3d for proper layering

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

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

* feat: glass button styling for background image visibility

icon buttons now have translucent backgrounds when a
background image is set, ensuring they remain visible
against any background:

- ShareButton gets glass background
- playlist page icon-btn (edit, delete) gets glass bg
- HiddenTagsFilter eyeball toggle gets glass bg
- glass button CSS variables set dynamically in layout
when background image is present
- respects light/dark theme with appropriate opacity

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

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

* fix: consistent glass button styling and preserve bg image on refresh

- unified queue/action buttons across tag, liked, playlist, and album pages
to use glass button CSS variables (--glass-btn-bg, --glass-btn-border)
- only apply background image changes when preferences are actually loaded
to prevent clearing the background image on refresh/hydration

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

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

* feat: use playing track artwork as background option

adds a new toggle in settings to use the currently playing
track's artwork as the background image:

- new ui_settings.use_playing_artwork_as_background option
- when enabled, overrides custom background image URL
- background changes dynamically as tracks change
- disables the custom URL input when enabled
- playing artwork never tiles (always cover)

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

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

* feat: blur and tile playing artwork background

- playing artwork now tiles in a 4x4 grid (25% size)
- applies 40px blur for smooth, ambient effect
- uses body::before pseudo-element with scale(1.1) to prevent blur edge artifacts
- custom background images remain unblurred

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

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

* fix: fall back to custom bg when playing track has no artwork

when "use playing artwork as background" is enabled but the
current track has no artwork, now falls back to the custom
background URL if one is set (instead of showing nothing)

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

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

* feat: add subtle text glow for readability against backgrounds

adds a --text-shadow CSS variable that provides a soft glow effect
around gray metadata text when a background image is set. this improves
readability without being visually heavy like a drop shadow.

applied to:
- album page metadata (type, title, meta, artist link)
- tag page track count subtitle
- settings page section headers

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

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

* fix: use proper fallbacks for glass button styling

when no background image is set, buttons should fall back to
transparent backgrounds and standard border colors rather than
hardcoded dark theme glass values.

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

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

---------

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 16c2e00a bd1f579e

Changed files
+509 -66
.claude
commands
backend
frontend
-1
.claude/commands/deploy.md
··· 46 46 ## post-deployment 47 47 48 48 remind the user to: 49 - - verify staging at https://stg.plyr.fm (frontend auto-deploys) 50 49 - monitor fly.io dashboard for backend deployment status 51 50 - check https://plyr.fm once deployment completes
+36
backend/alembic/versions/2025_12_16_000000_add_ui_settings_jsonb.py
··· 1 + """add ui_settings jsonb to preferences 2 + 3 + Revision ID: a1b2c3d4e5f6 4 + Revises: 37cc1d6980c3 5 + Create Date: 2025-12-16 00:00:00.000000 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + from sqlalchemy.dialects import postgresql 13 + 14 + from alembic import op 15 + 16 + # revision identifiers, used by Alembic. 17 + revision: str = "a1b2c3d4e5f6" 18 + down_revision: str | None = "37cc1d6980c3" 19 + branch_labels: str | Sequence[str] | None = None 20 + depends_on: str | Sequence[str] | None = None 21 + 22 + 23 + def upgrade() -> None: 24 + op.add_column( 25 + "user_preferences", 26 + sa.Column( 27 + "ui_settings", 28 + postgresql.JSONB(astext_type=sa.Text()), 29 + server_default=sa.text("'{}'::jsonb"), 30 + nullable=False, 31 + ), 32 + ) 33 + 34 + 35 + def downgrade() -> None: 36 + op.drop_column("user_preferences", "ui_settings")
+10 -1
backend/src/backend/api/preferences.py
··· 1 1 """user preferences api endpoints.""" 2 2 3 - from typing import Annotated 3 + from typing import Annotated, Any 4 4 5 5 from fastapi import APIRouter, Depends 6 6 from pydantic import BaseModel, field_validator ··· 31 31 show_sensitive_artwork: bool = False 32 32 show_liked_on_profile: bool = False 33 33 support_url: str | None = None 34 + # extensible UI settings (background_image_url, glass_effects, custom_colors, etc.) 35 + ui_settings: dict[str, Any] = {} 34 36 35 37 36 38 class PreferencesUpdate(BaseModel): ··· 44 46 show_sensitive_artwork: bool | None = None 45 47 show_liked_on_profile: bool | None = None 46 48 support_url: str | None = None 49 + ui_settings: dict[str, Any] | None = None 47 50 48 51 @field_validator("support_url", mode="before") 49 52 @classmethod ··· 104 107 show_sensitive_artwork=prefs.show_sensitive_artwork, 105 108 show_liked_on_profile=prefs.show_liked_on_profile, 106 109 support_url=prefs.support_url, 110 + ui_settings=prefs.ui_settings or {}, 107 111 ) 108 112 109 113 ··· 143 147 if update.show_liked_on_profile is not None 144 148 else False, 145 149 support_url=update.support_url, 150 + ui_settings=update.ui_settings or {}, 146 151 ) 147 152 db.add(prefs) 148 153 else: ··· 164 169 if update.support_url is not None: 165 170 # allow clearing by setting to empty string 166 171 prefs.support_url = update.support_url if update.support_url else None 172 + if update.ui_settings is not None: 173 + # merge with existing settings to allow partial updates 174 + prefs.ui_settings = {**(prefs.ui_settings or {}), **update.ui_settings} 167 175 168 176 await db.commit() 169 177 await db.refresh(prefs) ··· 182 190 show_sensitive_artwork=prefs.show_sensitive_artwork, 183 191 show_liked_on_profile=prefs.show_liked_on_profile, 184 192 support_url=prefs.support_url, 193 + ui_settings=prefs.ui_settings or {}, 185 194 )
+9
backend/src/backend/models/preferences.py
··· 65 65 # artist support link (Ko-fi, Patreon, etc.) 66 66 support_url: Mapped[str | None] = mapped_column(String, nullable=True) 67 67 68 + # extensible UI settings (colors, background image, glass effects, etc.) 69 + # schema-less to avoid migrations for new UI preferences 70 + ui_settings: Mapped[dict] = mapped_column( 71 + JSONB, 72 + nullable=False, 73 + default=dict, 74 + server_default=text("'{}'::jsonb"), 75 + ) 76 + 68 77 # metadata 69 78 created_at: Mapped[datetime] = mapped_column( 70 79 DateTime(timezone=True),
+80
backend/tests/api/test_preferences.py
··· 257 257 json={"support_url": "random-string"}, 258 258 ) 259 259 assert response.status_code == 422 # validation error 260 + 261 + 262 + async def test_get_preferences_includes_ui_settings( 263 + client_no_teal: AsyncClient, 264 + ): 265 + """should return ui_settings field in preferences response.""" 266 + response = await client_no_teal.get("/preferences/") 267 + assert response.status_code == 200 268 + 269 + data = response.json() 270 + assert "ui_settings" in data 271 + # default should be empty dict 272 + assert data["ui_settings"] == {} 273 + 274 + 275 + async def test_set_ui_settings( 276 + client_no_teal: AsyncClient, 277 + ): 278 + """should update ui_settings preference.""" 279 + response = await client_no_teal.post( 280 + "/preferences/", 281 + json={ 282 + "ui_settings": { 283 + "glass_enabled": True, 284 + "background_image_url": "https://example.com/bg.jpg", 285 + } 286 + }, 287 + ) 288 + assert response.status_code == 200 289 + 290 + data = response.json() 291 + assert data["ui_settings"]["glass_enabled"] is True 292 + assert data["ui_settings"]["background_image_url"] == "https://example.com/bg.jpg" 293 + 294 + 295 + async def test_ui_settings_partial_update( 296 + client_no_teal: AsyncClient, 297 + ): 298 + """should merge ui_settings on partial update.""" 299 + # first set some settings 300 + await client_no_teal.post( 301 + "/preferences/", 302 + json={"ui_settings": {"glass_enabled": True, "theme_color": "#ff0000"}}, 303 + ) 304 + 305 + # then update just one setting 306 + response = await client_no_teal.post( 307 + "/preferences/", 308 + json={"ui_settings": {"glass_enabled": False}}, 309 + ) 310 + assert response.status_code == 200 311 + 312 + data = response.json() 313 + # new value should override 314 + assert data["ui_settings"]["glass_enabled"] is False 315 + # old value should be preserved 316 + assert data["ui_settings"]["theme_color"] == "#ff0000" 317 + 318 + 319 + async def test_ui_settings_persists_after_other_update( 320 + client_no_teal: AsyncClient, 321 + ): 322 + """ui_settings should persist when updating other preferences.""" 323 + # set ui_settings 324 + await client_no_teal.post( 325 + "/preferences/", 326 + json={"ui_settings": {"glass_enabled": True}}, 327 + ) 328 + 329 + # update a different preference 330 + response = await client_no_teal.post( 331 + "/preferences/", 332 + json={"auto_advance": False}, 333 + ) 334 + assert response.status_code == 200 335 + 336 + data = response.json() 337 + # ui_settings should still be set 338 + assert data["ui_settings"]["glass_enabled"] is True 339 + assert data["auto_advance"] is False
+4 -2
frontend/src/lib/components/Header.svelte
··· 151 151 152 152 <style> 153 153 header { 154 - border-bottom: 1px solid var(--border-default); 154 + border-bottom: 1px solid var(--glass-border, var(--border-default)); 155 155 margin-bottom: 2rem; 156 156 position: sticky; 157 157 top: 0; 158 158 z-index: 50; 159 - background: var(--bg-primary); 159 + background: var(--glass-bg, var(--bg-primary)); 160 + backdrop-filter: var(--glass-blur, none); 161 + -webkit-backdrop-filter: var(--glass-blur, none); 160 162 } 161 163 162 164 .header-content {
+6 -5
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 133 133 display: inline-flex; 134 134 align-items: center; 135 135 gap: 0.3rem; 136 - padding: 0.25rem; 137 - background: transparent; 138 - border: none; 136 + padding: 0.35rem; 137 + background: var(--glass-btn-bg, transparent); 138 + border: 1px solid var(--glass-btn-border, transparent); 139 139 color: var(--text-tertiary); 140 140 cursor: pointer; 141 - transition: color 0.15s; 142 - border-radius: 4px; 141 + transition: all 0.15s; 142 + border-radius: 6px; 143 143 } 144 144 145 145 .filter-toggle:hover { 146 146 color: var(--text-secondary); 147 + background: var(--glass-btn-bg-hover, var(--bg-hover, transparent)); 147 148 } 148 149 149 150 .filter-toggle.has-filters {
+1 -2
frontend/src/lib/components/Queue.svelte
··· 242 242 flex-direction: column; 243 243 height: 100%; 244 244 padding: 1.5rem 1.25rem calc(var(--player-height, 0px) + 40px + env(safe-area-inset-bottom, 0px)); 245 - background: var(--bg-primary); 246 - border-left: 1px solid var(--border-subtle); 245 + background: transparent; 247 246 gap: 1rem; 248 247 } 249 248
+4 -3
frontend/src/lib/components/ShareButton.svelte
··· 36 36 37 37 <style> 38 38 .share-btn { 39 - background: transparent; 40 - border: 1px solid var(--border-default); 41 - border-radius: 4px; 39 + background: var(--glass-btn-bg, transparent); 40 + border: 1px solid var(--glass-btn-border, var(--border-default)); 41 + border-radius: 6px; 42 42 width: 32px; 43 43 height: 32px; 44 44 padding: 0; ··· 53 53 } 54 54 55 55 .share-btn:hover { 56 + background: var(--glass-btn-bg-hover, transparent); 56 57 border-color: var(--accent); 57 58 color: var(--accent); 58 59 }
+72 -11
frontend/src/lib/components/TrackItem.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { browser } from '$app/environment'; 2 4 import ShareButton from './ShareButton.svelte'; 3 5 import AddToMenu from './AddToMenu.svelte'; 4 6 import TrackActionsMenu from './TrackActionsMenu.svelte'; ··· 114 116 // also update the track object itself 115 117 track.like_count = likeCount; 116 118 } 119 + 120 + // wheel effect: tracks rotate based on distance from viewport center 121 + let containerEl: HTMLDivElement; 122 + let rotateX = $state(0); 123 + let translateZ = $state(0); 124 + 125 + onMount(() => { 126 + if (!browser) return; 127 + 128 + const MAX_ROTATION = 2; // max degrees of rotation (very subtle) 129 + 130 + function updateWheelPosition() { 131 + if (!containerEl) return; 132 + 133 + const rect = containerEl.getBoundingClientRect(); 134 + const viewportCenter = window.innerHeight / 2; 135 + const itemCenter = rect.top + rect.height / 2; 136 + 137 + // distance from viewport center, normalized (-1 to 1) 138 + const distanceFromCenter = (itemCenter - viewportCenter) / viewportCenter; 139 + 140 + // convex wheel: items above tilt toward viewer (positive), below tilt away (negative) 141 + rotateX = -distanceFromCenter * MAX_ROTATION; 142 + 143 + // z-translate: items at center are closest, edges recede slightly 144 + translateZ = (1 - Math.abs(distanceFromCenter)) * 3 - 1.5; 145 + } 146 + 147 + // use passive scroll listener for performance 148 + window.addEventListener('scroll', updateWheelPosition, { passive: true }); 149 + // also update on resize 150 + window.addEventListener('resize', updateWheelPosition, { passive: true }); 151 + // initial position 152 + updateWheelPosition(); 153 + 154 + return () => { 155 + window.removeEventListener('scroll', updateWheelPosition); 156 + window.removeEventListener('resize', updateWheelPosition); 157 + }; 158 + }); 117 159 </script> 118 160 119 - <div class="track-container" class:playing={isPlaying} class:likers-tooltip-open={showLikersTooltip}> 161 + <div 162 + class="track-container" 163 + class:playing={isPlaying} 164 + class:likers-tooltip-open={showLikersTooltip} 165 + bind:this={containerEl} 166 + style="transform: perspective(1000px) rotateX({rotateX}deg) translateZ({translateZ}px);" 167 + > 120 168 {#if showIndex} 121 169 <span class="track-index">{index + 1}</span> 122 170 {/if} ··· 328 376 display: flex; 329 377 align-items: center; 330 378 gap: 0.75rem; 331 - background: var(--bg-secondary); 332 - border: 1px solid var(--border-subtle); 333 - border-left: 3px solid transparent; 379 + background: var(--track-bg, var(--bg-secondary)); 380 + border: 1px solid var(--track-border, var(--border-subtle)); 381 + border-radius: 8px; 334 382 padding: 1rem; 335 - transition: all 0.15s ease-in-out; 383 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 384 + transform-origin: center center; 385 + transform-style: preserve-3d; 386 + will-change: transform; 387 + transition: 388 + box-shadow 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94), 389 + background 0.15s ease-out, 390 + border-color 0.15s ease-out; 336 391 } 337 392 338 393 .track-index { ··· 345 400 } 346 401 347 402 .track-container:hover { 348 - background: var(--bg-tertiary); 349 - border-left-color: var(--accent); 350 - border-color: var(--border-default); 403 + background: var(--track-bg-hover, var(--bg-tertiary)); 404 + border-color: color-mix(in srgb, var(--accent) 15%, var(--track-border-hover, var(--border-default))); 405 + box-shadow: 406 + 0 1px 3px rgba(0, 0, 0, 0.06), 407 + 0 0 8px color-mix(in srgb, var(--accent) 8%, transparent); 408 + } 409 + 410 + .track-container:active { 411 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 412 + transition-duration: 0.08s; 351 413 } 352 414 353 415 .track-container.playing { 354 - background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); 355 - border-left-color: var(--accent); 356 - border-color: color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 416 + background: color-mix(in srgb, var(--accent) 10%, var(--track-bg-playing, var(--bg-tertiary))); 417 + border-color: color-mix(in srgb, var(--accent) 20%, var(--track-border, var(--border-subtle))); 357 418 } 358 419 359 420 /* elevate entire track container when likers tooltip is open
+4 -2
frontend/src/lib/components/player/Player.svelte
··· 359 359 bottom: 0; 360 360 left: 0; 361 361 right: 0; 362 - background: var(--bg-tertiary); 363 - border-top: 1px solid var(--border-default); 362 + background: var(--glass-bg, var(--bg-tertiary)); 363 + backdrop-filter: var(--glass-blur, none); 364 + -webkit-backdrop-filter: var(--glass-blur, none); 365 + border-top: 1px solid var(--glass-border, var(--border-default)); 364 366 padding: 0.75rem 2rem; 365 367 padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); 366 368 z-index: 100;
+43 -2
frontend/src/lib/preferences.svelte.ts
··· 5 5 6 6 export type Theme = 'dark' | 'light' | 'system'; 7 7 8 + export interface UiSettings { 9 + background_image_url?: string; 10 + background_tile?: boolean; 11 + use_playing_artwork_as_background?: boolean; 12 + } 13 + 8 14 export interface Preferences { 9 15 accent_color: string | null; 10 16 auto_advance: boolean; ··· 16 22 show_sensitive_artwork: boolean; 17 23 show_liked_on_profile: boolean; 18 24 support_url: string | null; 25 + ui_settings: UiSettings; 19 26 } 20 27 21 28 const DEFAULT_PREFERENCES: Preferences = { ··· 28 35 teal_needs_reauth: false, 29 36 show_sensitive_artwork: false, 30 37 show_liked_on_profile: false, 31 - support_url: null 38 + support_url: null, 39 + ui_settings: {} 32 40 }; 33 41 34 42 class PreferencesManager { ··· 80 88 return this.data?.support_url ?? DEFAULT_PREFERENCES.support_url; 81 89 } 82 90 91 + get uiSettings(): UiSettings { 92 + return this.data?.ui_settings ?? DEFAULT_PREFERENCES.ui_settings; 93 + } 94 + 83 95 setTheme(theme: Theme): void { 84 96 if (browser) { 85 97 localStorage.setItem('theme', theme); ··· 132 144 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 133 145 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 134 146 show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile, 135 - support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url 147 + support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url, 148 + ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings 136 149 }; 137 150 } else { 138 151 this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme }; ··· 169 182 } catch (error) { 170 183 console.error('failed to save preferences:', error); 171 184 // revert on error by refetching 185 + await this.fetch(); 186 + } 187 + } 188 + 189 + async updateUiSettings(updates: Partial<UiSettings>): Promise<void> { 190 + if (!browser || !auth.isAuthenticated) return; 191 + 192 + // optimistic update - merge with existing 193 + if (this.data) { 194 + this.data = { 195 + ...this.data, 196 + ui_settings: { ...this.data.ui_settings, ...updates } 197 + }; 198 + } 199 + 200 + try { 201 + const response = await fetch(`${API_URL}/preferences/`, { 202 + method: 'POST', 203 + headers: { 'Content-Type': 'application/json' }, 204 + credentials: 'include', 205 + body: JSON.stringify({ ui_settings: updates }) 206 + }); 207 + if (!response.ok) { 208 + console.error('failed to save ui settings:', response.status); 209 + await this.fetch(); 210 + } 211 + } catch (error) { 212 + console.error('failed to save ui settings:', error); 172 213 await this.fetch(); 173 214 } 174 215 }
+91 -21
frontend/src/routes/+layout.svelte
··· 85 85 document.documentElement.style.setProperty('--queue-width', queueWidth); 86 86 }); 87 87 88 + // apply background image from ui_settings or playing track artwork 89 + // only apply when preferences are actually loaded (not null) to avoid clearing on initial load 90 + $effect(() => { 91 + if (!browser) return; 92 + // don't clear bg image if preferences haven't loaded yet 93 + if (!preferences.loaded) return; 94 + 95 + const uiSettings = preferences.uiSettings; 96 + const root = document.documentElement; 97 + 98 + // determine background image URL 99 + // priority: playing artwork (if enabled and available) > custom URL 100 + let bgImageUrl: string | undefined; 101 + let isUsingPlayingArtwork = false; 102 + if (uiSettings.use_playing_artwork_as_background && player.currentTrack?.image_url) { 103 + bgImageUrl = player.currentTrack.image_url; 104 + isUsingPlayingArtwork = true; 105 + } else if (uiSettings.background_image_url) { 106 + // fall back to custom URL (whether playing artwork is enabled or not) 107 + bgImageUrl = uiSettings.background_image_url; 108 + } 109 + 110 + if (bgImageUrl) { 111 + root.style.setProperty('--bg-image', `url(${bgImageUrl})`); 112 + // playing artwork tiles in a 4x4 grid with blur, custom image respects tile setting 113 + const shouldTile = isUsingPlayingArtwork || uiSettings.background_tile; 114 + root.style.setProperty('--bg-image-mode', shouldTile ? 'repeat' : 'no-repeat'); 115 + // playing artwork: 25% size (4x4 grid), custom: auto if tiled, cover if not 116 + root.style.setProperty('--bg-image-size', isUsingPlayingArtwork ? '25%' : (uiSettings.background_tile ? 'auto' : 'cover')); 117 + // blur playing artwork for smoother look 118 + root.style.setProperty('--bg-blur', isUsingPlayingArtwork ? '40px' : '0px'); 119 + // glass button styling for visibility against background images 120 + const isLight = root.classList.contains('theme-light'); 121 + root.style.setProperty('--glass-btn-bg', isLight ? 'rgba(255, 255, 255, 0.8)' : 'rgba(18, 18, 18, 0.8)'); 122 + root.style.setProperty('--glass-btn-bg-hover', isLight ? 'rgba(255, 255, 255, 0.9)' : 'rgba(30, 30, 30, 0.9)'); 123 + root.style.setProperty('--glass-btn-border', isLight ? 'rgba(0, 0, 0, 0.12)' : 'rgba(255, 255, 255, 0.12)'); 124 + // very subtle text outline for readability against background images 125 + root.style.setProperty('--text-shadow', isLight ? '0 0 8px rgba(255, 255, 255, 0.6)' : '0 0 8px rgba(0, 0, 0, 0.6)'); 126 + } else { 127 + root.style.removeProperty('--bg-image'); 128 + root.style.removeProperty('--bg-image-mode'); 129 + root.style.removeProperty('--bg-image-size'); 130 + root.style.removeProperty('--bg-blur'); 131 + root.style.removeProperty('--glass-btn-bg'); 132 + root.style.removeProperty('--glass-btn-bg-hover'); 133 + root.style.removeProperty('--glass-btn-border'); 134 + root.style.removeProperty('--text-shadow'); 135 + } 136 + }); 137 + 88 138 const SEEK_AMOUNT = 10; // seconds 89 139 let previousVolume = 0.7; // for mute toggle 90 140 ··· 408 458 --success: #4ade80; 409 459 --warning: #fbbf24; 410 460 --error: #ef4444; 461 + 462 + /* glass effects (dark theme) */ 463 + --glass-bg: rgba(20, 20, 20, 0.75); 464 + --glass-blur: blur(12px); 465 + --glass-border: rgba(255, 255, 255, 0.06); 466 + 467 + /* track item glass (no blur, just translucent) */ 468 + --track-bg: rgba(18, 18, 18, 0.88); 469 + --track-bg-hover: rgba(24, 24, 24, 0.92); 470 + --track-bg-playing: rgba(18, 18, 18, 0.88); 471 + --track-border: rgba(255, 255, 255, 0.06); 472 + --track-border-hover: rgba(255, 255, 255, 0.1); 411 473 } 412 474 413 475 /* light theme overrides */ ··· 434 496 --success: #16a34a; 435 497 --warning: #d97706; 436 498 --error: #dc2626; 437 - } 438 499 439 - /* light theme specific overrides for components */ 440 - :global(:root.theme-light) :global(.track-container) { 441 - background: var(--bg-secondary); 442 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); 443 - } 500 + /* glass effects (light theme) */ 501 + --glass-bg: rgba(250, 250, 250, 0.75); 502 + --glass-border: rgba(0, 0, 0, 0.06); 444 503 445 - :global(:root.theme-light) :global(.track-container:hover) { 446 - background: var(--bg-tertiary); 447 - } 448 - 449 - :global(:root.theme-light) :global(.track-container.playing) { 450 - background: color-mix(in srgb, var(--accent) 8%, white); 451 - border-color: color-mix(in srgb, var(--accent) 30%, white); 452 - } 453 - 454 - :global(:root.theme-light) :global(header) { 455 - background: var(--bg-primary); 456 - border-color: var(--border-default); 504 + /* track item glass (light theme) */ 505 + --track-bg: rgba(255, 255, 255, 0.94); 506 + --track-bg-hover: rgba(250, 250, 250, 0.96); 507 + --track-bg-playing: rgba(255, 255, 255, 0.94); 508 + --track-border: rgba(0, 0, 0, 0.08); 509 + --track-border-hover: rgba(0, 0, 0, 0.12); 457 510 } 458 511 512 + /* light theme specific overrides for components */ 459 513 :global(:root.theme-light) :global(.tag-badge) { 460 514 background: color-mix(in srgb, var(--accent) 12%, white); 461 515 color: var(--accent-muted); ··· 465 519 margin: 0; 466 520 padding: 0; 467 521 font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 468 - background: var(--bg-primary); 522 + background-color: var(--bg-primary); 469 523 color: var(--text-primary); 470 524 -webkit-font-smoothing: antialiased; 471 525 } 472 526 527 + /* background image with blur effect */ 528 + :global(body::before) { 529 + content: ''; 530 + position: fixed; 531 + inset: 0; 532 + background-image: var(--bg-image, none); 533 + background-repeat: var(--bg-image-mode, no-repeat); 534 + background-size: var(--bg-image-size, cover); 535 + background-position: center; 536 + filter: blur(var(--bg-blur, 0px)); 537 + transform: scale(1.1); /* prevent blur edge artifacts */ 538 + z-index: -1; 539 + } 540 + 473 541 .app-layout { 474 542 display: flex; 475 543 min-height: 100vh; /* fallback for browsers without dvh support */ ··· 500 568 right: 0; 501 569 width: min(360px, 100%); 502 570 height: 100vh; /* fallback for browsers without dvh support */ 503 - background: var(--bg-primary); 504 - border-left: 1px solid var(--border-subtle); 571 + background: var(--glass-bg, var(--bg-primary)); 572 + backdrop-filter: var(--glass-blur, none); 573 + -webkit-backdrop-filter: var(--glass-blur, none); 574 + border-left: 1px solid var(--glass-border, var(--border-subtle)); 505 575 z-index: 50; 506 576 } 507 577
+4 -2
frontend/src/routes/+layout.ts
··· 22 22 teal_needs_reauth: false, 23 23 show_sensitive_artwork: false, 24 24 show_liked_on_profile: false, 25 - support_url: null 25 + support_url: null, 26 + ui_settings: {} 26 27 }; 27 28 28 29 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 63 64 teal_needs_reauth: prefsData.teal_needs_reauth ?? false, 64 65 show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false, 65 66 show_liked_on_profile: prefsData.show_liked_on_profile ?? false, 66 - support_url: prefsData.support_url ?? null 67 + support_url: prefsData.support_url ?? null, 68 + ui_settings: prefsData.ui_settings ?? {} 67 69 }; 68 70 } 69 71 } catch (e) {
+3 -3
frontend/src/routes/liked/+page.svelte
··· 371 371 display: flex; 372 372 align-items: center; 373 373 gap: 0.5rem; 374 - border: none; 375 - background: transparent; 374 + background: var(--glass-btn-bg, transparent); 376 375 color: var(--text-primary); 377 - border: 1px solid var(--border-default); 376 + border: 1px solid var(--glass-btn-border, var(--border-default)); 378 377 } 379 378 380 379 .queue-button:hover, 381 380 .reorder-button:hover { 381 + background: var(--glass-btn-bg-hover, transparent); 382 382 border-color: var(--accent); 383 383 color: var(--accent); 384 384 }
+10 -7
frontend/src/routes/playlist/[id]/+page.svelte
··· 1521 1521 justify-content: center; 1522 1522 width: 32px; 1523 1523 height: 32px; 1524 - background: transparent; 1525 - border: 1px solid var(--border-default); 1526 - border-radius: 4px; 1527 - color: var(--text-tertiary); 1524 + background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75)); 1525 + border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1)); 1526 + border-radius: 6px; 1527 + color: var(--text-secondary); 1528 1528 cursor: pointer; 1529 1529 transition: all 0.15s; 1530 1530 } 1531 1531 1532 1532 .icon-btn:hover { 1533 + background: var(--glass-btn-bg-hover, rgba(30, 30, 30, 0.85)); 1533 1534 border-color: var(--accent); 1534 1535 color: var(--accent); 1535 1536 } 1536 1537 1537 1538 .icon-btn.danger:hover { 1539 + background: rgba(239, 68, 68, 0.15); 1538 1540 border-color: #ef4444; 1539 1541 color: #ef4444; 1540 1542 } ··· 1542 1544 .icon-btn.active { 1543 1545 border-color: var(--accent); 1544 1546 color: var(--accent); 1545 - background: color-mix(in srgb, var(--accent) 10%, transparent); 1547 + background: color-mix(in srgb, var(--accent) 20%, var(--glass-btn-bg, rgba(18, 18, 18, 0.75))); 1546 1548 } 1547 1549 1548 1550 /* playlist actions */ ··· 1577 1579 } 1578 1580 1579 1581 .queue-button { 1580 - background: transparent; 1582 + background: var(--glass-btn-bg, transparent); 1581 1583 color: var(--text-primary); 1582 - border: 1px solid var(--border-default); 1584 + border: 1px solid var(--glass-btn-border, var(--border-default)); 1583 1585 } 1584 1586 1585 1587 .queue-button:hover { 1588 + background: var(--glass-btn-bg-hover, transparent); 1586 1589 border-color: var(--accent); 1587 1590 color: var(--accent); 1588 1591 }
+122
frontend/src/routes/settings/+page.svelte
··· 21 21 let currentTheme = $derived(preferences.theme); 22 22 let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 23 23 let autoAdvance = $derived(preferences.autoAdvance); 24 + let backgroundImageUrl = $derived(preferences.uiSettings.background_image_url ?? ''); 25 + let backgroundTile = $derived(preferences.uiSettings.background_tile ?? false); 26 + let usePlayingArtwork = $derived(preferences.uiSettings.use_playing_artwork_as_background ?? false); 24 27 // developer token state 25 28 let creatingToken = $state(false); 26 29 let developerToken = $state<string | null>(null); ··· 141 144 142 145 function selectPreset(color: string) { 143 146 applyColor(color); 147 + } 148 + 149 + // background image state - initialize once, don't sync reactively 150 + let backgroundInput = $state(preferences.uiSettings.background_image_url ?? ''); 151 + let backgroundInputInitialized = $state(false); 152 + 153 + // only sync from server on initial load, not on every change 154 + $effect(() => { 155 + if (!backgroundInputInitialized && preferences.loaded) { 156 + backgroundInput = preferences.uiSettings.background_image_url ?? ''; 157 + backgroundInputInitialized = true; 158 + } 159 + }); 160 + 161 + async function saveBackgroundImage() { 162 + const url = backgroundInput.trim(); 163 + await preferences.updateUiSettings({ 164 + background_image_url: url || '' 165 + }); 166 + if (url) { 167 + toast.success('background image set'); 168 + } else { 169 + toast.success('background image cleared'); 170 + } 171 + } 172 + 173 + async function saveBackgroundTile(tile: boolean) { 174 + await preferences.updateUiSettings({ background_tile: tile }); 175 + toast.success(tile ? 'background tiled' : 'background stretched'); 176 + } 177 + 178 + async function saveUsePlayingArtwork(enabled: boolean) { 179 + await preferences.updateUiSettings({ use_playing_artwork_as_background: enabled }); 180 + toast.success(enabled ? 'using playing artwork as background' : 'using custom background'); 144 181 } 145 182 146 183 function selectTheme(theme: Theme) { ··· 416 453 </div> 417 454 </div> 418 455 </div> 456 + 457 + <div class="setting-row"> 458 + <div class="setting-info"> 459 + <h3>background image</h3> 460 + <p>set a custom background image (URL)</p> 461 + </div> 462 + <div class="background-controls"> 463 + <input 464 + type="url" 465 + class="background-input" 466 + placeholder="https://..." 467 + bind:value={backgroundInput} 468 + onblur={saveBackgroundImage} 469 + onkeydown={(e) => e.key === 'Enter' && saveBackgroundImage()} 470 + disabled={usePlayingArtwork} 471 + /> 472 + {#if backgroundImageUrl && !usePlayingArtwork} 473 + <label class="tile-toggle"> 474 + <input 475 + type="checkbox" 476 + checked={backgroundTile} 477 + onchange={(e) => saveBackgroundTile((e.target as HTMLInputElement).checked)} 478 + /> 479 + <span>tile</span> 480 + </label> 481 + {/if} 482 + </div> 483 + </div> 484 + 485 + <div class="setting-row"> 486 + <div class="setting-info"> 487 + <h3>playing artwork as background</h3> 488 + <p>use the currently playing track's artwork as background (overrides custom image)</p> 489 + </div> 490 + <label class="toggle-switch"> 491 + <input 492 + type="checkbox" 493 + checked={usePlayingArtwork} 494 + onchange={(e) => saveUsePlayingArtwork((e.target as HTMLInputElement).checked)} 495 + /> 496 + <span class="toggle-slider"></span> 497 + </label> 498 + </div> 419 499 </div> 420 500 </section> 421 501 ··· 786 866 letter-spacing: 0.08em; 787 867 color: var(--text-tertiary); 788 868 margin-bottom: 0.75rem; 869 + text-shadow: var(--text-shadow, none); 789 870 } 790 871 791 872 .settings-card { ··· 931 1012 .preset-btn.active { 932 1013 border-color: var(--text-primary); 933 1014 box-shadow: 0 0 0 1px var(--bg-secondary); 1015 + } 1016 + 1017 + /* background controls */ 1018 + .background-controls { 1019 + display: flex; 1020 + align-items: center; 1021 + gap: 0.75rem; 1022 + flex-shrink: 0; 1023 + } 1024 + 1025 + .background-input { 1026 + width: 200px; 1027 + padding: 0.5rem 0.75rem; 1028 + background: var(--bg-primary); 1029 + border: 1px solid var(--border-default); 1030 + border-radius: 6px; 1031 + color: var(--text-primary); 1032 + font-size: 0.85rem; 1033 + font-family: inherit; 1034 + } 1035 + 1036 + .background-input:focus { 1037 + outline: none; 1038 + border-color: var(--accent); 1039 + } 1040 + 1041 + .background-input::placeholder { 1042 + color: var(--text-tertiary); 1043 + } 1044 + 1045 + .tile-toggle { 1046 + display: flex; 1047 + align-items: center; 1048 + gap: 0.4rem; 1049 + font-size: 0.8rem; 1050 + color: var(--text-secondary); 1051 + cursor: pointer; 1052 + } 1053 + 1054 + .tile-toggle input { 1055 + accent-color: var(--accent); 934 1056 } 935 1057 936 1058 /* toggle switch */
+3 -2
frontend/src/routes/tag/[name]/+page.svelte
··· 136 136 font-size: 0.95rem; 137 137 color: var(--text-tertiary); 138 138 margin: 0; 139 + text-shadow: var(--text-shadow, none); 139 140 } 140 141 141 142 .btn-queue-all { ··· 143 144 align-items: center; 144 145 gap: 0.5rem; 145 146 padding: 0.6rem 1rem; 146 - background: transparent; 147 - border: 1px solid var(--accent); 147 + background: var(--glass-btn-bg, transparent); 148 + border: 1px solid var(--glass-btn-border, var(--accent)); 148 149 color: var(--accent); 149 150 border-radius: 6px; 150 151 font-size: 0.9rem;
+7 -2
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 895 895 letter-spacing: 0.1em; 896 896 color: var(--text-tertiary); 897 897 margin: 0; 898 + text-shadow: var(--text-shadow, none); 898 899 } 899 900 900 901 .album-title { ··· 906 907 word-wrap: break-word; 907 908 overflow-wrap: break-word; 908 909 hyphens: auto; 910 + text-shadow: var(--text-shadow, none); 909 911 } 910 912 911 913 .album-meta { ··· 914 916 gap: 0.75rem; 915 917 font-size: 0.95rem; 916 918 color: var(--text-secondary); 919 + text-shadow: var(--text-shadow, none); 917 920 } 918 921 919 922 .artist-link { ··· 921 924 text-decoration: none; 922 925 font-weight: 600; 923 926 transition: color 0.2s; 927 + text-shadow: var(--text-shadow, none); 924 928 } 925 929 926 930 .artist-link:hover { ··· 963 967 } 964 968 965 969 .queue-button { 966 - background: transparent; 970 + background: var(--glass-btn-bg, transparent); 967 971 color: var(--text-primary); 968 - border: 1px solid var(--border-default); 972 + border: 1px solid var(--glass-btn-border, var(--border-default)); 969 973 } 970 974 971 975 .queue-button:hover { 976 + background: var(--glass-btn-bg-hover, transparent); 972 977 border-color: var(--accent); 973 978 color: var(--accent); 974 979 }