audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: ambient theme overhaul — rename aura→live, add full UI tinting (#1132)

The ambient weather theme now tints glass surfaces, borders, track cards,
and backgrounds (not just the gradient). Rename "aura" to "live" in all
user-facing text/CSS. Add the live button to mobile ProfileMenu.

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

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
28ddc6db ffc7b5dd

+250 -25
+161 -4
frontend/src/lib/ambient.svelte.ts
··· 1 - // ambient weather theme — location-aware atmospheric background 1 + // ambient weather theme — location-aware atmospheric background + full tinting 2 2 import { browser } from '$app/environment'; 3 3 4 4 interface WeatherData { ··· 11 11 lat: number; 12 12 lon: number; 13 13 } 14 + 15 + interface RGB { 16 + r: number; 17 + g: number; 18 + b: number; 19 + } 20 + 21 + interface RGBA extends RGB { 22 + a: number; 23 + } 24 + 25 + /** CSS variables to tint and their blend strengths */ 26 + const TINTED_VARS: [string, number][] = [ 27 + // glass surfaces (15%) 28 + ['--glass-bg', 0.15], 29 + ['--glass-border', 0.15], 30 + // borders (8-10%) 31 + ['--border-subtle', 0.08], 32 + ['--border-default', 0.10], 33 + // track cards (10-14%) 34 + ['--track-bg', 0.10], 35 + ['--track-bg-hover', 0.12], 36 + ['--track-bg-playing', 0.10], 37 + ['--track-border', 0.12], 38 + ['--track-border-hover', 0.14], 39 + // backgrounds (6-8%) 40 + ['--bg-secondary', 0.06], 41 + ['--bg-tertiary', 0.07], 42 + ['--bg-hover', 0.08], 43 + ]; 14 44 15 45 function getConditionLabel(code: number): string { 16 46 if (code <= 3) return 'clear'; ··· 79 109 return 'linear-gradient(135deg, rgb(35, 38, 48), rgb(28, 30, 40), rgb(22, 24, 32))'; 80 110 } 81 111 112 + /** one representative tint color per weather condition */ 113 + function computeTint(w: WeatherData): RGB { 114 + const condition = getConditionLabel(w.weathercode); 115 + const warmth = Math.min(1, Math.max(0, (w.temperature - 5) / 30)); 116 + 117 + if (w.is_day) { 118 + switch (condition) { 119 + case 'clear': return { 120 + r: Math.round(200 + warmth * 40), 121 + g: Math.round(160 + warmth * 20), 122 + b: Math.round(60 - warmth * 20) 123 + }; 124 + case 'fog': return { r: 160, g: 155, b: 175 }; 125 + case 'rain': return { r: 70, g: 100, b: 140 }; 126 + case 'snow': return { r: 190, g: 200, b: 215 }; 127 + case 'storm': return { r: 80, g: 50, b: 100 }; 128 + default: return { r: 130, g: 140, b: 155 }; // cloudy 129 + } 130 + } 131 + switch (condition) { 132 + case 'clear': return { r: 30, g: 30, b: 80 }; 133 + case 'fog': return { r: 50, g: 50, b: 65 }; 134 + case 'rain': return { r: 20, g: 30, b: 50 }; 135 + case 'snow': return { r: 50, g: 55, b: 75 }; 136 + case 'storm': return { r: 25, g: 15, b: 40 }; 137 + default: return { r: 35, g: 38, b: 48 }; // cloudy 138 + } 139 + } 140 + 141 + /** parse rgb(), rgba(), or hex color string to RGBA */ 142 + function parseColor(css: string): RGBA | null { 143 + const trimmed = css.trim(); 144 + 145 + // hex: #rgb, #rrggbb, #rrggbbaa 146 + if (trimmed.startsWith('#')) { 147 + const hex = trimmed.slice(1); 148 + if (hex.length === 3) { 149 + return { 150 + r: parseInt(hex[0] + hex[0], 16), 151 + g: parseInt(hex[1] + hex[1], 16), 152 + b: parseInt(hex[2] + hex[2], 16), 153 + a: 1 154 + }; 155 + } 156 + if (hex.length >= 6) { 157 + return { 158 + r: parseInt(hex.slice(0, 2), 16), 159 + g: parseInt(hex.slice(2, 4), 16), 160 + b: parseInt(hex.slice(4, 6), 16), 161 + a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1 162 + }; 163 + } 164 + return null; 165 + } 166 + 167 + // rgba(r, g, b, a) or rgb(r, g, b) 168 + const match = trimmed.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/); 169 + if (match) { 170 + return { 171 + r: parseFloat(match[1]), 172 + g: parseFloat(match[2]), 173 + b: parseFloat(match[3]), 174 + a: match[4] !== undefined ? parseFloat(match[4]) : 1 175 + }; 176 + } 177 + return null; 178 + } 179 + 180 + /** blend a base color with a tint at given strength, preserving alpha */ 181 + function blendColor(base: RGBA, tint: RGB, strength: number): string { 182 + const r = Math.round(base.r + (tint.r - base.r) * strength); 183 + const g = Math.round(base.g + (tint.g - base.g) * strength); 184 + const b = Math.round(base.b + (tint.b - base.b) * strength); 185 + if (base.a < 1) { 186 + return `rgba(${r}, ${g}, ${b}, ${base.a})`; 187 + } 188 + return `rgb(${r}, ${g}, ${b})`; 189 + } 190 + 82 191 class AmbientManager { 83 192 enabled = $state(false); 84 193 location = $state<AmbientLocation | null>(null); ··· 89 198 private fetchIntervalId: ReturnType<typeof window.setInterval> | null = null; 90 199 private lastFetchTime = 0; 91 200 private readonly STALE_MS = 30 * 60 * 1000; // 30 minutes 201 + private baseValues: Map<string, string> = new Map(); 92 202 93 203 get gradient(): string | null { 94 204 if (!this.weather) return null; ··· 162 272 this.fetchIntervalId = null; 163 273 } 164 274 165 - document.body.classList.remove('ambient-active'); 166 - document.documentElement.style.removeProperty('--ambient-gradient'); 275 + this.clearFromDOM(); 167 276 } 168 277 169 278 applyToDOM(): void { 170 - if (!browser || !this.enabled || !this.gradient) return; 279 + if (!browser || !this.enabled || !this.gradient || !this.weather) return; 280 + 171 281 document.body.classList.add('ambient-active'); 172 282 document.documentElement.style.setProperty('--ambient-gradient', this.gradient); 283 + 284 + // snapshot base values on first apply (or after refresh) 285 + if (this.baseValues.size === 0) { 286 + this.snapshotBaseValues(); 287 + } 288 + 289 + const tint = computeTint(this.weather); 290 + for (const [varName, strength] of TINTED_VARS) { 291 + const baseVal = this.baseValues.get(varName); 292 + if (!baseVal) continue; 293 + const parsed = parseColor(baseVal); 294 + if (!parsed) continue; 295 + document.documentElement.style.setProperty(varName, blendColor(parsed, tint, strength)); 296 + } 173 297 } 174 298 175 299 clearFromDOM(): void { 176 300 if (!browser) return; 301 + 302 + // remove tinted variable overrides 303 + for (const [varName] of TINTED_VARS) { 304 + document.documentElement.style.removeProperty(varName); 305 + } 306 + this.baseValues.clear(); 307 + 177 308 document.body.classList.remove('ambient-active'); 178 309 document.documentElement.style.removeProperty('--ambient-gradient'); 310 + } 311 + 312 + /** remove overrides, re-snapshot base values from current theme, re-apply tint */ 313 + refreshBaseValues(): void { 314 + if (!browser || !this.enabled) return; 315 + 316 + // temporarily remove our overrides so getComputedStyle returns the theme's base 317 + for (const [varName] of TINTED_VARS) { 318 + document.documentElement.style.removeProperty(varName); 319 + } 320 + this.baseValues.clear(); 321 + 322 + // re-apply on next frame so computed styles reflect the new theme 323 + window.requestAnimationFrame(() => { 324 + this.applyToDOM(); 325 + }); 326 + } 327 + 328 + private snapshotBaseValues(): void { 329 + const computed = window.getComputedStyle(document.documentElement); 330 + for (const [varName] of TINTED_VARS) { 331 + const val = computed.getPropertyValue(varName).trim(); 332 + if (val) { 333 + this.baseValues.set(varName, val); 334 + } 335 + } 179 336 } 180 337 181 338 private requestLocation(): Promise<AmbientLocation> {
+63 -2
frontend/src/lib/components/ProfileMenu.svelte
··· 4 4 import { page } from '$app/stores'; 5 5 import { queue } from '$lib/queue.svelte'; 6 6 import { preferences, type Theme } from '$lib/preferences.svelte'; 7 + import { ambient } from '$lib/ambient.svelte'; 7 8 import { API_URL } from '$lib/config'; 8 9 import type { User, LinkedAccount } from '$lib/types'; 9 10 import HandleAutocomplete from './HandleAutocomplete.svelte'; ··· 44 45 let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 45 46 let autoAdvance = $derived(preferences.autoAdvance); 46 47 let currentTheme = $derived(preferences.theme); 48 + 49 + let ambientEnabled = $derived(ambient.enabled); 50 + let ambientGradient = $derived(ambient.gradient); 51 + let ambientLoading = $derived(ambient.loading); 52 + 53 + async function toggleAmbient(enabled: boolean) { 54 + if (enabled) { 55 + await ambient.enable(); 56 + } else { 57 + ambient.disable(); 58 + } 59 + } 47 60 48 61 // derive linked accounts (excluding current user) 49 62 const otherAccounts = $derived( ··· 360 373 {#each themes as theme} 361 374 <button 362 375 class="theme-btn" 363 - class:active={currentTheme === theme.value} 364 - onclick={() => selectTheme(theme.value)} 376 + class:active={currentTheme === theme.value && !ambientEnabled} 377 + onclick={() => { if (ambientEnabled) ambient.disable(); selectTheme(theme.value); }} 365 378 title={theme.label} 366 379 > 367 380 {#if theme.icon === 'moon'} ··· 383 396 <span>{theme.label}</span> 384 397 </button> 385 398 {/each} 399 + <button 400 + class="theme-btn live-btn" 401 + class:active={ambientEnabled} 402 + class:live-loading={ambientLoading} 403 + onclick={() => ambientEnabled ? ambient.disable() : toggleAmbient(true)} 404 + title={ambientEnabled ? ambient.conditionLabel ?? 'live' : '?'} 405 + > 406 + {#if ambientEnabled && ambientGradient} 407 + <div class="live-orb-inline" style="background: {ambientGradient}"></div> 408 + {:else if ambientLoading} 409 + <div class="live-orb-inline live-orb-loading"></div> 410 + {:else} 411 + <svg viewBox="0 0 24 24" fill="currentColor" opacity="0.3"> 412 + <circle cx="12" cy="12" r="9" /> 413 + </svg> 414 + {/if} 415 + <span>{ambientEnabled ? 'live' : '?'}</span> 416 + </button> 386 417 </div> 387 418 </section> 388 419 ··· 866 897 font-size: var(--text-xs); 867 898 text-transform: uppercase; 868 899 letter-spacing: 0.05em; 900 + } 901 + 902 + /* live theme button */ 903 + .live-btn { 904 + border-style: dashed; 905 + } 906 + 907 + .live-btn.active { 908 + border-style: solid; 909 + } 910 + 911 + .live-btn.live-loading { 912 + opacity: 0.6; 913 + pointer-events: none; 914 + } 915 + 916 + .live-orb-inline { 917 + width: 18px; 918 + height: 18px; 919 + border-radius: var(--radius-full); 920 + animation: live-breathe 6s ease-in-out infinite; 921 + } 922 + 923 + .live-orb-loading { 924 + background: var(--border-default); 925 + } 926 + 927 + @keyframes live-breathe { 928 + 0%, 100% { transform: scale(1); opacity: 0.85; } 929 + 50% { transform: scale(1.08); opacity: 1; } 869 930 } 870 931 871 932 .color-row {
+7
frontend/src/routes/+layout.svelte
··· 194 194 } 195 195 }); 196 196 197 + // re-snapshot base values when theme changes while ambient is active 198 + $effect(() => { 199 + if (!browser) return; 200 + const _theme = preferences.theme; 201 + if (ambient.enabled) ambient.refreshBaseValues(); 202 + }); 203 + 197 204 const SEEK_AMOUNT = 10; // seconds 198 205 let previousVolume = 0.7; // for mute toggle 199 206
+17 -17
frontend/src/routes/settings/+page.svelte
··· 38 38 let loadingTokens = $state(false); 39 39 let revokingToken = $state<string | null>(null); 40 40 41 - // ambient aura state 41 + // ambient live state 42 42 let ambientEnabled = $derived(ambient.enabled); 43 43 let ambientGradient = $derived(ambient.gradient); 44 44 let ambientLoading = $derived(ambient.loading); ··· 472 472 </button> 473 473 {/each} 474 474 <button 475 - class="theme-btn aura-btn" 475 + class="theme-btn live-btn" 476 476 class:active={ambientEnabled} 477 - class:aura-loading={ambientLoading} 477 + class:live-loading={ambientLoading} 478 478 onclick={() => ambientEnabled ? ambient.disable() : toggleAmbient(true)} 479 - title={ambientEnabled ? ambientCondition ?? 'aura' : '?'} 479 + title={ambientEnabled ? ambientCondition ?? 'live' : '?'} 480 480 > 481 481 {#if ambientEnabled && ambientGradient} 482 - <div class="aura-orb-inline" style="background: {ambientGradient}"></div> 482 + <div class="live-orb-inline" style="background: {ambientGradient}"></div> 483 483 {:else if ambientLoading} 484 - <div class="aura-orb-inline aura-orb-loading"></div> 484 + <div class="live-orb-inline live-orb-loading"></div> 485 485 {:else} 486 486 <svg viewBox="0 0 24 24" fill="currentColor" opacity="0.3"> 487 487 <circle cx="12" cy="12" r="9" /> 488 488 </svg> 489 489 {/if} 490 - <span>{ambientEnabled ? 'aura' : '?'}</span> 490 + <span>{ambientEnabled ? 'live' : '?'}</span> 491 491 </button> 492 492 </div> 493 493 </div> 494 494 495 495 {#if ambientError} 496 - <div class="aura-error-row"> 496 + <div class="live-error-row"> 497 497 <span>{ambientError}</span> 498 498 </div> 499 499 {/if} ··· 1136 1136 accent-color: var(--accent); 1137 1137 } 1138 1138 1139 - /* aura theme button */ 1140 - .aura-btn { 1139 + /* live theme button */ 1140 + .live-btn { 1141 1141 border-style: dashed; 1142 1142 } 1143 1143 1144 - .aura-btn.active { 1144 + .live-btn.active { 1145 1145 border-style: solid; 1146 1146 } 1147 1147 1148 - .aura-btn.aura-loading { 1148 + .live-btn.live-loading { 1149 1149 opacity: 0.6; 1150 1150 pointer-events: none; 1151 1151 } 1152 1152 1153 - .aura-orb-inline { 1153 + .live-orb-inline { 1154 1154 width: 18px; 1155 1155 height: 18px; 1156 1156 border-radius: var(--radius-full); 1157 - animation: aura-breathe 6s ease-in-out infinite; 1157 + animation: live-breathe 6s ease-in-out infinite; 1158 1158 } 1159 1159 1160 - .aura-orb-loading { 1160 + .live-orb-loading { 1161 1161 background: var(--border-default); 1162 1162 } 1163 1163 1164 - @keyframes aura-breathe { 1164 + @keyframes live-breathe { 1165 1165 0%, 100% { transform: scale(1); opacity: 0.85; } 1166 1166 50% { transform: scale(1.08); opacity: 1; } 1167 1167 } 1168 1168 1169 - .aura-error-row { 1169 + .live-error-row { 1170 1170 font-size: var(--text-sm); 1171 1171 color: var(--error); 1172 1172 padding: 0.5rem 0;
+2 -2
loq.toml
··· 107 107 108 108 [[rules]] 109 109 path = "frontend/src/lib/components/ProfileMenu.svelte" 110 - max_lines = 1087 110 + max_lines = 1148 111 111 112 112 [[rules]] 113 113 path = "frontend/src/lib/components/Queue.svelte" ··· 139 139 140 140 [[rules]] 141 141 path = "frontend/src/routes/+layout.svelte" 142 - max_lines = 736 142 + max_lines = 743 143 143 144 144 [[rules]] 145 145 path = "frontend/src/routes/costs/+page.svelte"