audio streaming app plyr.fm

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

+509 -66
-1
.claude/commands/deploy.md
··· 46 ## post-deployment 47 48 remind the user to: 49 - - verify staging at https://stg.plyr.fm (frontend auto-deploys) 50 - monitor fly.io dashboard for backend deployment status 51 - check https://plyr.fm once deployment completes
··· 46 ## post-deployment 47 48 remind the user to: 49 - monitor fly.io dashboard for backend deployment status 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 """user preferences api endpoints.""" 2 3 - from typing import Annotated 4 5 from fastapi import APIRouter, Depends 6 from pydantic import BaseModel, field_validator ··· 31 show_sensitive_artwork: bool = False 32 show_liked_on_profile: bool = False 33 support_url: str | None = None 34 35 36 class PreferencesUpdate(BaseModel): ··· 44 show_sensitive_artwork: bool | None = None 45 show_liked_on_profile: bool | None = None 46 support_url: str | None = None 47 48 @field_validator("support_url", mode="before") 49 @classmethod ··· 104 show_sensitive_artwork=prefs.show_sensitive_artwork, 105 show_liked_on_profile=prefs.show_liked_on_profile, 106 support_url=prefs.support_url, 107 ) 108 109 ··· 143 if update.show_liked_on_profile is not None 144 else False, 145 support_url=update.support_url, 146 ) 147 db.add(prefs) 148 else: ··· 164 if update.support_url is not None: 165 # allow clearing by setting to empty string 166 prefs.support_url = update.support_url if update.support_url else None 167 168 await db.commit() 169 await db.refresh(prefs) ··· 182 show_sensitive_artwork=prefs.show_sensitive_artwork, 183 show_liked_on_profile=prefs.show_liked_on_profile, 184 support_url=prefs.support_url, 185 )
··· 1 """user preferences api endpoints.""" 2 3 + from typing import Annotated, Any 4 5 from fastapi import APIRouter, Depends 6 from pydantic import BaseModel, field_validator ··· 31 show_sensitive_artwork: bool = False 32 show_liked_on_profile: bool = False 33 support_url: str | None = None 34 + # extensible UI settings (background_image_url, glass_effects, custom_colors, etc.) 35 + ui_settings: dict[str, Any] = {} 36 37 38 class PreferencesUpdate(BaseModel): ··· 46 show_sensitive_artwork: bool | None = None 47 show_liked_on_profile: bool | None = None 48 support_url: str | None = None 49 + ui_settings: dict[str, Any] | None = None 50 51 @field_validator("support_url", mode="before") 52 @classmethod ··· 107 show_sensitive_artwork=prefs.show_sensitive_artwork, 108 show_liked_on_profile=prefs.show_liked_on_profile, 109 support_url=prefs.support_url, 110 + ui_settings=prefs.ui_settings or {}, 111 ) 112 113 ··· 147 if update.show_liked_on_profile is not None 148 else False, 149 support_url=update.support_url, 150 + ui_settings=update.ui_settings or {}, 151 ) 152 db.add(prefs) 153 else: ··· 169 if update.support_url is not None: 170 # allow clearing by setting to empty string 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} 175 176 await db.commit() 177 await db.refresh(prefs) ··· 190 show_sensitive_artwork=prefs.show_sensitive_artwork, 191 show_liked_on_profile=prefs.show_liked_on_profile, 192 support_url=prefs.support_url, 193 + ui_settings=prefs.ui_settings or {}, 194 )
+9
backend/src/backend/models/preferences.py
··· 65 # artist support link (Ko-fi, Patreon, etc.) 66 support_url: Mapped[str | None] = mapped_column(String, nullable=True) 67 68 # metadata 69 created_at: Mapped[datetime] = mapped_column( 70 DateTime(timezone=True),
··· 65 # artist support link (Ko-fi, Patreon, etc.) 66 support_url: Mapped[str | None] = mapped_column(String, nullable=True) 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 + 77 # metadata 78 created_at: Mapped[datetime] = mapped_column( 79 DateTime(timezone=True),
+80
backend/tests/api/test_preferences.py
··· 257 json={"support_url": "random-string"}, 258 ) 259 assert response.status_code == 422 # validation error
··· 257 json={"support_url": "random-string"}, 258 ) 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 152 <style> 153 header { 154 - border-bottom: 1px solid var(--border-default); 155 margin-bottom: 2rem; 156 position: sticky; 157 top: 0; 158 z-index: 50; 159 - background: var(--bg-primary); 160 } 161 162 .header-content {
··· 151 152 <style> 153 header { 154 + border-bottom: 1px solid var(--glass-border, var(--border-default)); 155 margin-bottom: 2rem; 156 position: sticky; 157 top: 0; 158 z-index: 50; 159 + background: var(--glass-bg, var(--bg-primary)); 160 + backdrop-filter: var(--glass-blur, none); 161 + -webkit-backdrop-filter: var(--glass-blur, none); 162 } 163 164 .header-content {
+6 -5
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 133 display: inline-flex; 134 align-items: center; 135 gap: 0.3rem; 136 - padding: 0.25rem; 137 - background: transparent; 138 - border: none; 139 color: var(--text-tertiary); 140 cursor: pointer; 141 - transition: color 0.15s; 142 - border-radius: 4px; 143 } 144 145 .filter-toggle:hover { 146 color: var(--text-secondary); 147 } 148 149 .filter-toggle.has-filters {
··· 133 display: inline-flex; 134 align-items: center; 135 gap: 0.3rem; 136 + padding: 0.35rem; 137 + background: var(--glass-btn-bg, transparent); 138 + border: 1px solid var(--glass-btn-border, transparent); 139 color: var(--text-tertiary); 140 cursor: pointer; 141 + transition: all 0.15s; 142 + border-radius: 6px; 143 } 144 145 .filter-toggle:hover { 146 color: var(--text-secondary); 147 + background: var(--glass-btn-bg-hover, var(--bg-hover, transparent)); 148 } 149 150 .filter-toggle.has-filters {
+1 -2
frontend/src/lib/components/Queue.svelte
··· 242 flex-direction: column; 243 height: 100%; 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); 247 gap: 1rem; 248 } 249
··· 242 flex-direction: column; 243 height: 100%; 244 padding: 1.5rem 1.25rem calc(var(--player-height, 0px) + 40px + env(safe-area-inset-bottom, 0px)); 245 + background: transparent; 246 gap: 1rem; 247 } 248
+4 -3
frontend/src/lib/components/ShareButton.svelte
··· 36 37 <style> 38 .share-btn { 39 - background: transparent; 40 - border: 1px solid var(--border-default); 41 - border-radius: 4px; 42 width: 32px; 43 height: 32px; 44 padding: 0; ··· 53 } 54 55 .share-btn:hover { 56 border-color: var(--accent); 57 color: var(--accent); 58 }
··· 36 37 <style> 38 .share-btn { 39 + background: var(--glass-btn-bg, transparent); 40 + border: 1px solid var(--glass-btn-border, var(--border-default)); 41 + border-radius: 6px; 42 width: 32px; 43 height: 32px; 44 padding: 0; ··· 53 } 54 55 .share-btn:hover { 56 + background: var(--glass-btn-bg-hover, transparent); 57 border-color: var(--accent); 58 color: var(--accent); 59 }
+72 -11
frontend/src/lib/components/TrackItem.svelte
··· 1 <script lang="ts"> 2 import ShareButton from './ShareButton.svelte'; 3 import AddToMenu from './AddToMenu.svelte'; 4 import TrackActionsMenu from './TrackActionsMenu.svelte'; ··· 114 // also update the track object itself 115 track.like_count = likeCount; 116 } 117 </script> 118 119 - <div class="track-container" class:playing={isPlaying} class:likers-tooltip-open={showLikersTooltip}> 120 {#if showIndex} 121 <span class="track-index">{index + 1}</span> 122 {/if} ··· 328 display: flex; 329 align-items: center; 330 gap: 0.75rem; 331 - background: var(--bg-secondary); 332 - border: 1px solid var(--border-subtle); 333 - border-left: 3px solid transparent; 334 padding: 1rem; 335 - transition: all 0.15s ease-in-out; 336 } 337 338 .track-index { ··· 345 } 346 347 .track-container:hover { 348 - background: var(--bg-tertiary); 349 - border-left-color: var(--accent); 350 - border-color: var(--border-default); 351 } 352 353 .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)); 357 } 358 359 /* elevate entire track container when likers tooltip is open
··· 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { browser } from '$app/environment'; 4 import ShareButton from './ShareButton.svelte'; 5 import AddToMenu from './AddToMenu.svelte'; 6 import TrackActionsMenu from './TrackActionsMenu.svelte'; ··· 116 // also update the track object itself 117 track.like_count = likeCount; 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 + }); 159 </script> 160 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 + > 168 {#if showIndex} 169 <span class="track-index">{index + 1}</span> 170 {/if} ··· 376 display: flex; 377 align-items: center; 378 gap: 0.75rem; 379 + background: var(--track-bg, var(--bg-secondary)); 380 + border: 1px solid var(--track-border, var(--border-subtle)); 381 + border-radius: 8px; 382 padding: 1rem; 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; 391 } 392 393 .track-index { ··· 400 } 401 402 .track-container:hover { 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; 413 } 414 415 .track-container.playing { 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))); 418 } 419 420 /* elevate entire track container when likers tooltip is open
+4 -2
frontend/src/lib/components/player/Player.svelte
··· 359 bottom: 0; 360 left: 0; 361 right: 0; 362 - background: var(--bg-tertiary); 363 - border-top: 1px solid var(--border-default); 364 padding: 0.75rem 2rem; 365 padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); 366 z-index: 100;
··· 359 bottom: 0; 360 left: 0; 361 right: 0; 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)); 366 padding: 0.75rem 2rem; 367 padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); 368 z-index: 100;
+43 -2
frontend/src/lib/preferences.svelte.ts
··· 5 6 export type Theme = 'dark' | 'light' | 'system'; 7 8 export interface Preferences { 9 accent_color: string | null; 10 auto_advance: boolean; ··· 16 show_sensitive_artwork: boolean; 17 show_liked_on_profile: boolean; 18 support_url: string | null; 19 } 20 21 const DEFAULT_PREFERENCES: Preferences = { ··· 28 teal_needs_reauth: false, 29 show_sensitive_artwork: false, 30 show_liked_on_profile: false, 31 - support_url: null 32 }; 33 34 class PreferencesManager { ··· 80 return this.data?.support_url ?? DEFAULT_PREFERENCES.support_url; 81 } 82 83 setTheme(theme: Theme): void { 84 if (browser) { 85 localStorage.setItem('theme', theme); ··· 132 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 133 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 134 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 136 }; 137 } else { 138 this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme }; ··· 169 } catch (error) { 170 console.error('failed to save preferences:', error); 171 // revert on error by refetching 172 await this.fetch(); 173 } 174 }
··· 5 6 export type Theme = 'dark' | 'light' | 'system'; 7 8 + export interface UiSettings { 9 + background_image_url?: string; 10 + background_tile?: boolean; 11 + use_playing_artwork_as_background?: boolean; 12 + } 13 + 14 export interface Preferences { 15 accent_color: string | null; 16 auto_advance: boolean; ··· 22 show_sensitive_artwork: boolean; 23 show_liked_on_profile: boolean; 24 support_url: string | null; 25 + ui_settings: UiSettings; 26 } 27 28 const DEFAULT_PREFERENCES: Preferences = { ··· 35 teal_needs_reauth: false, 36 show_sensitive_artwork: false, 37 show_liked_on_profile: false, 38 + support_url: null, 39 + ui_settings: {} 40 }; 41 42 class PreferencesManager { ··· 88 return this.data?.support_url ?? DEFAULT_PREFERENCES.support_url; 89 } 90 91 + get uiSettings(): UiSettings { 92 + return this.data?.ui_settings ?? DEFAULT_PREFERENCES.ui_settings; 93 + } 94 + 95 setTheme(theme: Theme): void { 96 if (browser) { 97 localStorage.setItem('theme', theme); ··· 144 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 145 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 146 show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile, 147 + support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url, 148 + ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings 149 }; 150 } else { 151 this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme }; ··· 182 } catch (error) { 183 console.error('failed to save preferences:', error); 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); 213 await this.fetch(); 214 } 215 }
+91 -21
frontend/src/routes/+layout.svelte
··· 85 document.documentElement.style.setProperty('--queue-width', queueWidth); 86 }); 87 88 const SEEK_AMOUNT = 10; // seconds 89 let previousVolume = 0.7; // for mute toggle 90 ··· 408 --success: #4ade80; 409 --warning: #fbbf24; 410 --error: #ef4444; 411 } 412 413 /* light theme overrides */ ··· 434 --success: #16a34a; 435 --warning: #d97706; 436 --error: #dc2626; 437 - } 438 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 - } 444 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); 457 } 458 459 :global(:root.theme-light) :global(.tag-badge) { 460 background: color-mix(in srgb, var(--accent) 12%, white); 461 color: var(--accent-muted); ··· 465 margin: 0; 466 padding: 0; 467 font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 468 - background: var(--bg-primary); 469 color: var(--text-primary); 470 -webkit-font-smoothing: antialiased; 471 } 472 473 .app-layout { 474 display: flex; 475 min-height: 100vh; /* fallback for browsers without dvh support */ ··· 500 right: 0; 501 width: min(360px, 100%); 502 height: 100vh; /* fallback for browsers without dvh support */ 503 - background: var(--bg-primary); 504 - border-left: 1px solid var(--border-subtle); 505 z-index: 50; 506 } 507
··· 85 document.documentElement.style.setProperty('--queue-width', queueWidth); 86 }); 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 + 138 const SEEK_AMOUNT = 10; // seconds 139 let previousVolume = 0.7; // for mute toggle 140 ··· 458 --success: #4ade80; 459 --warning: #fbbf24; 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); 473 } 474 475 /* light theme overrides */ ··· 496 --success: #16a34a; 497 --warning: #d97706; 498 --error: #dc2626; 499 500 + /* glass effects (light theme) */ 501 + --glass-bg: rgba(250, 250, 250, 0.75); 502 + --glass-border: rgba(0, 0, 0, 0.06); 503 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); 510 } 511 512 + /* light theme specific overrides for components */ 513 :global(:root.theme-light) :global(.tag-badge) { 514 background: color-mix(in srgb, var(--accent) 12%, white); 515 color: var(--accent-muted); ··· 519 margin: 0; 520 padding: 0; 521 font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; 522 + background-color: var(--bg-primary); 523 color: var(--text-primary); 524 -webkit-font-smoothing: antialiased; 525 } 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 + 541 .app-layout { 542 display: flex; 543 min-height: 100vh; /* fallback for browsers without dvh support */ ··· 568 right: 0; 569 width: min(360px, 100%); 570 height: 100vh; /* fallback for browsers without dvh support */ 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)); 575 z-index: 50; 576 } 577
+4 -2
frontend/src/routes/+layout.ts
··· 22 teal_needs_reauth: false, 23 show_sensitive_artwork: false, 24 show_liked_on_profile: false, 25 - support_url: null 26 }; 27 28 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 63 teal_needs_reauth: prefsData.teal_needs_reauth ?? false, 64 show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false, 65 show_liked_on_profile: prefsData.show_liked_on_profile ?? false, 66 - support_url: prefsData.support_url ?? null 67 }; 68 } 69 } catch (e) {
··· 22 teal_needs_reauth: false, 23 show_sensitive_artwork: false, 24 show_liked_on_profile: false, 25 + support_url: null, 26 + ui_settings: {} 27 }; 28 29 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 64 teal_needs_reauth: prefsData.teal_needs_reauth ?? false, 65 show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false, 66 show_liked_on_profile: prefsData.show_liked_on_profile ?? false, 67 + support_url: prefsData.support_url ?? null, 68 + ui_settings: prefsData.ui_settings ?? {} 69 }; 70 } 71 } catch (e) {
+3 -3
frontend/src/routes/liked/+page.svelte
··· 371 display: flex; 372 align-items: center; 373 gap: 0.5rem; 374 - border: none; 375 - background: transparent; 376 color: var(--text-primary); 377 - border: 1px solid var(--border-default); 378 } 379 380 .queue-button:hover, 381 .reorder-button:hover { 382 border-color: var(--accent); 383 color: var(--accent); 384 }
··· 371 display: flex; 372 align-items: center; 373 gap: 0.5rem; 374 + background: var(--glass-btn-bg, transparent); 375 color: var(--text-primary); 376 + border: 1px solid var(--glass-btn-border, var(--border-default)); 377 } 378 379 .queue-button:hover, 380 .reorder-button:hover { 381 + background: var(--glass-btn-bg-hover, transparent); 382 border-color: var(--accent); 383 color: var(--accent); 384 }
+10 -7
frontend/src/routes/playlist/[id]/+page.svelte
··· 1521 justify-content: center; 1522 width: 32px; 1523 height: 32px; 1524 - background: transparent; 1525 - border: 1px solid var(--border-default); 1526 - border-radius: 4px; 1527 - color: var(--text-tertiary); 1528 cursor: pointer; 1529 transition: all 0.15s; 1530 } 1531 1532 .icon-btn:hover { 1533 border-color: var(--accent); 1534 color: var(--accent); 1535 } 1536 1537 .icon-btn.danger:hover { 1538 border-color: #ef4444; 1539 color: #ef4444; 1540 } ··· 1542 .icon-btn.active { 1543 border-color: var(--accent); 1544 color: var(--accent); 1545 - background: color-mix(in srgb, var(--accent) 10%, transparent); 1546 } 1547 1548 /* playlist actions */ ··· 1577 } 1578 1579 .queue-button { 1580 - background: transparent; 1581 color: var(--text-primary); 1582 - border: 1px solid var(--border-default); 1583 } 1584 1585 .queue-button:hover { 1586 border-color: var(--accent); 1587 color: var(--accent); 1588 }
··· 1521 justify-content: center; 1522 width: 32px; 1523 height: 32px; 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 cursor: pointer; 1529 transition: all 0.15s; 1530 } 1531 1532 .icon-btn:hover { 1533 + background: var(--glass-btn-bg-hover, rgba(30, 30, 30, 0.85)); 1534 border-color: var(--accent); 1535 color: var(--accent); 1536 } 1537 1538 .icon-btn.danger:hover { 1539 + background: rgba(239, 68, 68, 0.15); 1540 border-color: #ef4444; 1541 color: #ef4444; 1542 } ··· 1544 .icon-btn.active { 1545 border-color: var(--accent); 1546 color: var(--accent); 1547 + background: color-mix(in srgb, var(--accent) 20%, var(--glass-btn-bg, rgba(18, 18, 18, 0.75))); 1548 } 1549 1550 /* playlist actions */ ··· 1579 } 1580 1581 .queue-button { 1582 + background: var(--glass-btn-bg, transparent); 1583 color: var(--text-primary); 1584 + border: 1px solid var(--glass-btn-border, var(--border-default)); 1585 } 1586 1587 .queue-button:hover { 1588 + background: var(--glass-btn-bg-hover, transparent); 1589 border-color: var(--accent); 1590 color: var(--accent); 1591 }
+122
frontend/src/routes/settings/+page.svelte
··· 21 let currentTheme = $derived(preferences.theme); 22 let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 23 let autoAdvance = $derived(preferences.autoAdvance); 24 // developer token state 25 let creatingToken = $state(false); 26 let developerToken = $state<string | null>(null); ··· 141 142 function selectPreset(color: string) { 143 applyColor(color); 144 } 145 146 function selectTheme(theme: Theme) { ··· 416 </div> 417 </div> 418 </div> 419 </div> 420 </section> 421 ··· 786 letter-spacing: 0.08em; 787 color: var(--text-tertiary); 788 margin-bottom: 0.75rem; 789 } 790 791 .settings-card { ··· 931 .preset-btn.active { 932 border-color: var(--text-primary); 933 box-shadow: 0 0 0 1px var(--bg-secondary); 934 } 935 936 /* toggle switch */
··· 21 let currentTheme = $derived(preferences.theme); 22 let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 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); 27 // developer token state 28 let creatingToken = $state(false); 29 let developerToken = $state<string | null>(null); ··· 144 145 function selectPreset(color: string) { 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'); 181 } 182 183 function selectTheme(theme: Theme) { ··· 453 </div> 454 </div> 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> 499 </div> 500 </section> 501 ··· 866 letter-spacing: 0.08em; 867 color: var(--text-tertiary); 868 margin-bottom: 0.75rem; 869 + text-shadow: var(--text-shadow, none); 870 } 871 872 .settings-card { ··· 1012 .preset-btn.active { 1013 border-color: var(--text-primary); 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); 1056 } 1057 1058 /* toggle switch */
+3 -2
frontend/src/routes/tag/[name]/+page.svelte
··· 136 font-size: 0.95rem; 137 color: var(--text-tertiary); 138 margin: 0; 139 } 140 141 .btn-queue-all { ··· 143 align-items: center; 144 gap: 0.5rem; 145 padding: 0.6rem 1rem; 146 - background: transparent; 147 - border: 1px solid var(--accent); 148 color: var(--accent); 149 border-radius: 6px; 150 font-size: 0.9rem;
··· 136 font-size: 0.95rem; 137 color: var(--text-tertiary); 138 margin: 0; 139 + text-shadow: var(--text-shadow, none); 140 } 141 142 .btn-queue-all { ··· 144 align-items: center; 145 gap: 0.5rem; 146 padding: 0.6rem 1rem; 147 + background: var(--glass-btn-bg, transparent); 148 + border: 1px solid var(--glass-btn-border, var(--accent)); 149 color: var(--accent); 150 border-radius: 6px; 151 font-size: 0.9rem;
+7 -2
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 895 letter-spacing: 0.1em; 896 color: var(--text-tertiary); 897 margin: 0; 898 } 899 900 .album-title { ··· 906 word-wrap: break-word; 907 overflow-wrap: break-word; 908 hyphens: auto; 909 } 910 911 .album-meta { ··· 914 gap: 0.75rem; 915 font-size: 0.95rem; 916 color: var(--text-secondary); 917 } 918 919 .artist-link { ··· 921 text-decoration: none; 922 font-weight: 600; 923 transition: color 0.2s; 924 } 925 926 .artist-link:hover { ··· 963 } 964 965 .queue-button { 966 - background: transparent; 967 color: var(--text-primary); 968 - border: 1px solid var(--border-default); 969 } 970 971 .queue-button:hover { 972 border-color: var(--accent); 973 color: var(--accent); 974 }
··· 895 letter-spacing: 0.1em; 896 color: var(--text-tertiary); 897 margin: 0; 898 + text-shadow: var(--text-shadow, none); 899 } 900 901 .album-title { ··· 907 word-wrap: break-word; 908 overflow-wrap: break-word; 909 hyphens: auto; 910 + text-shadow: var(--text-shadow, none); 911 } 912 913 .album-meta { ··· 916 gap: 0.75rem; 917 font-size: 0.95rem; 918 color: var(--text-secondary); 919 + text-shadow: var(--text-shadow, none); 920 } 921 922 .artist-link { ··· 924 text-decoration: none; 925 font-weight: 600; 926 transition: color 0.2s; 927 + text-shadow: var(--text-shadow, none); 928 } 929 930 .artist-link:hover { ··· 967 } 968 969 .queue-button { 970 + background: var(--glass-btn-bg, transparent); 971 color: var(--text-primary); 972 + border: 1px solid var(--glass-btn-border, var(--border-default)); 973 } 974 975 .queue-button:hover { 976 + background: var(--glass-btn-bg-hover, transparent); 977 border-color: var(--accent); 978 color: var(--accent); 979 }