audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: make live a first-class theme with server-side persistence (#1134)

live (ambient weather) was a separate layer on top of dark/light/system,
causing bugs on account switch. now it's a peer theme — picking live
activates ambient, picking dark/light/system deactivates it.

backend: add theme column to user_preferences (default 'dark'), return
in GET/POST preferences responses. frontend: fetch() uses server theme
instead of always overriding with localStorage. localStorage is now just
a flash-prevention cache synced from the server response.

ambient.svelte.ts: replace initialize/enable/disable with
activate/deactivate, remove DID-scoped localStorage keys, use
device-global ambient_location with migration from old keys.

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

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
551a1665 5ee3f243

+161 -113
+34
backend/alembic/versions/2026_03_16_190207_40474bbaccb1_add_theme_column_to_user_preferences.py
··· 1 + """add theme column to user_preferences 2 + 3 + Revision ID: 40474bbaccb1 4 + Revises: 298ad5c58e0e 5 + Create Date: 2026-03-16 19:02:07.848608 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "40474bbaccb1" 17 + down_revision: str | Sequence[str] | None = "298ad5c58e0e" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "user_preferences", 26 + sa.Column( 27 + "theme", sa.String(), server_default=sa.text("'dark'"), nullable=False 28 + ), 29 + ) 30 + 31 + 32 + def downgrade() -> None: 33 + """Downgrade schema.""" 34 + op.drop_column("user_preferences", "theme")
+7
backend/src/backend/api/preferences.py
··· 23 23 class PreferencesResponse(BaseModel): 24 24 """user preferences response model.""" 25 25 26 + theme: str 26 27 accent_color: str 27 28 auto_advance: bool 28 29 allow_comments: bool ··· 41 42 class PreferencesUpdate(BaseModel): 42 43 """user preferences update model.""" 43 44 45 + theme: str | None = None 44 46 accent_color: str | None = None 45 47 auto_advance: bool | None = None 46 48 allow_comments: bool | None = None ··· 103 105 teal_needs_reauth = prefs.enable_teal_scrobbling and not has_scope 104 106 105 107 return PreferencesResponse( 108 + theme=prefs.theme, 106 109 accent_color=prefs.accent_color, 107 110 auto_advance=prefs.auto_advance, 108 111 allow_comments=prefs.allow_comments, ··· 133 136 # create new preferences 134 137 prefs = UserPreferences( 135 138 did=session.did, 139 + theme=update.theme or "dark", 136 140 accent_color=update.accent_color or "#6a9fff", 137 141 auto_advance=update.auto_advance 138 142 if update.auto_advance is not None ··· 158 162 db.add(prefs) 159 163 else: 160 164 # update existing 165 + if update.theme is not None: 166 + prefs.theme = update.theme 161 167 if update.accent_color is not None: 162 168 prefs.accent_color = update.accent_color 163 169 if update.auto_advance is not None: ··· 187 193 teal_needs_reauth = prefs.enable_teal_scrobbling and not has_scope 188 194 189 195 return PreferencesResponse( 196 + theme=prefs.theme, 190 197 accent_color=prefs.accent_color, 191 198 auto_advance=prefs.auto_advance, 192 199 allow_comments=prefs.allow_comments,
+3
backend/src/backend/models/preferences.py
··· 19 19 did: Mapped[str] = mapped_column(String, primary_key=True) 20 20 21 21 # ui preferences 22 + theme: Mapped[str] = mapped_column( 23 + String, nullable=False, default="dark", server_default=text("'dark'") 24 + ) 22 25 accent_color: Mapped[str] = mapped_column(String, nullable=False, default="#6a9fff") 23 26 auto_advance: Mapped[bool] = mapped_column( 24 27 Boolean, nullable=False, default=True, server_default=text("true")
+46 -34
frontend/src/lib/ambient.svelte.ts
··· 199 199 private lastFetchTime = 0; 200 200 private readonly STALE_MS = 30 * 60 * 1000; // 30 minutes 201 201 private baseValues: Map<string, string> = new Map(); 202 - private did: string | null = null; 203 - 204 - private key(name: string): string { 205 - return this.did ? `${name}:${this.did}` : name; 206 - } 207 202 208 203 get gradient(): string | null { 209 204 if (!this.weather) return null; ··· 219 214 return `${temp}° · ${condition} · ${time}`; 220 215 } 221 216 222 - initialize(did?: string): void { 223 - if (!browser) return; 224 - 225 - this.did = did ?? null; 226 - 227 - const stored = localStorage.getItem(this.key('ambient_enabled')); 228 - if (stored !== '1') return; 217 + /** activate ambient mode. returns false if geolocation denied or unavailable. */ 218 + async activate(): Promise<boolean> { 219 + if (!browser) return false; 229 220 230 - const locStr = localStorage.getItem(this.key('ambient_location')); 231 - if (!locStr) return; 221 + this.loading = true; 222 + this.error = null; 232 223 233 224 try { 234 - this.location = JSON.parse(locStr); 235 - } catch { 236 - return; 237 - } 225 + // check device-global location cache first 226 + let locStr = localStorage.getItem('ambient_location'); 238 227 239 - this.enabled = true; 240 - this.fetchWeather(); 241 - this.startRefreshCycle(); 242 - } 228 + // migrate from old DID-scoped keys if needed 229 + if (!locStr) { 230 + for (let i = 0; i < localStorage.length; i++) { 231 + const k = localStorage.key(i); 232 + if (k && k.startsWith('ambient_location:')) { 233 + locStr = localStorage.getItem(k); 234 + if (locStr) { 235 + localStorage.setItem('ambient_location', locStr); 236 + localStorage.removeItem(k); 237 + } 238 + break; 239 + } 240 + } 241 + } 243 242 244 - async enable(): Promise<void> { 245 - if (!browser) return; 243 + if (locStr) { 244 + try { 245 + this.location = JSON.parse(locStr); 246 + } catch { 247 + this.location = null; 248 + } 249 + } 246 250 247 - this.loading = true; 248 - this.error = null; 251 + // prompt geolocation if no cached location 252 + if (!this.location) { 253 + const coords = await this.requestLocation(); 254 + this.location = coords; 255 + localStorage.setItem('ambient_location', JSON.stringify(coords)); 256 + } 249 257 250 - try { 251 - const coords = await this.requestLocation(); 252 - this.location = coords; 253 - localStorage.setItem(this.key('ambient_location'), JSON.stringify(coords)); 254 - localStorage.setItem(this.key('ambient_enabled'), '1'); 255 258 this.enabled = true; 256 259 await this.fetchWeather(); 257 260 this.startRefreshCycle(); 261 + return true; 258 262 } catch (err) { 259 263 this.error = err instanceof GeolocationPositionError 260 264 ? 'location access denied — ambient mode needs your location to read the sky' 261 265 : 'could not determine location'; 262 266 this.enabled = false; 263 - localStorage.removeItem(this.key('ambient_enabled')); 267 + return false; 264 268 } finally { 265 269 this.loading = false; 266 270 } 267 271 } 268 272 269 - disable(): void { 273 + /** deactivate ambient mode. keeps cached location for next activation. */ 274 + deactivate(): void { 270 275 if (!browser) return; 271 276 272 277 this.enabled = false; 273 278 this.weather = null; 274 279 this.error = null; 275 - localStorage.setItem(this.key('ambient_enabled'), '0'); 276 280 277 281 if (this.fetchIntervalId !== null) { 278 282 window.clearInterval(this.fetchIntervalId); ··· 280 284 } 281 285 282 286 this.clearFromDOM(); 287 + 288 + // clean up old DID-scoped enabled keys 289 + for (let i = localStorage.length - 1; i >= 0; i--) { 290 + const k = localStorage.key(i); 291 + if (k && k.startsWith('ambient_enabled')) { 292 + localStorage.removeItem(k); 293 + } 294 + } 283 295 } 284 296 285 297 applyToDOM(): void {
+16 -31
frontend/src/lib/components/ProfileMenu.svelte
··· 46 46 let autoAdvance = $derived(preferences.autoAdvance); 47 47 let currentTheme = $derived(preferences.theme); 48 48 49 - let ambientEnabled = $derived(ambient.enabled); 50 49 let ambientGradient = $derived(ambient.gradient); 51 50 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 - } 60 51 61 52 // derive linked accounts (excluding current user) 62 53 const otherAccounts = $derived( ··· 373 364 {#each themes as theme} 374 365 <button 375 366 class="theme-btn" 376 - class:active={currentTheme === theme.value && !ambientEnabled} 377 - onclick={() => { if (ambientEnabled) ambient.disable(); selectTheme(theme.value); }} 378 - title={theme.label} 367 + class:live-btn={theme.icon === 'live'} 368 + class:active={currentTheme === theme.value} 369 + class:live-loading={theme.icon === 'live' && ambientLoading} 370 + onclick={() => selectTheme(theme.value)} 371 + title={theme.icon === 'live' ? (currentTheme === 'live' ? ambient.conditionLabel ?? 'live' : 'live') : theme.label} 379 372 > 380 - {#if theme.icon === 'moon'} 373 + {#if theme.icon === 'live'} 374 + {#if currentTheme === 'live' && ambientGradient} 375 + <div class="live-orb-inline" style="background: {ambientGradient}"></div> 376 + {:else if ambientLoading} 377 + <div class="live-orb-inline live-orb-loading"></div> 378 + {:else} 379 + <svg viewBox="0 0 24 24" fill="currentColor" opacity="0.3"> 380 + <circle cx="12" cy="12" r="9" /> 381 + </svg> 382 + {/if} 383 + {:else if theme.icon === 'moon'} 381 384 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 382 385 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 383 386 </svg> ··· 396 399 <span>{theme.label}</span> 397 400 </button> 398 401 {/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> 417 402 </div> 418 403 </section> 419 404
+32 -12
frontend/src/lib/preferences.svelte.ts
··· 2 2 import { browser } from '$app/environment'; 3 3 import { API_URL, getServerConfig } from '$lib/config'; 4 4 import { auth } from '$lib/auth.svelte'; 5 + import { ambient } from '$lib/ambient.svelte'; 5 6 6 - export type Theme = 'dark' | 'light' | 'system'; 7 + export type Theme = 'dark' | 'light' | 'system' | 'live'; 7 8 8 9 export interface UiSettings { 9 10 background_image_url?: string; ··· 122 123 } 123 124 } 124 125 125 - setTheme(theme: Theme): void { 126 + async setTheme(theme: Theme): Promise<void> { 126 127 if (browser) { 128 + if (theme === 'live') { 129 + // attempt activation — revert to dark if geolocation denied 130 + const ok = await ambient.activate(); 131 + if (!ok) { 132 + localStorage.setItem('theme', 'dark'); 133 + this.applyTheme('dark'); 134 + this.update({ theme: 'dark' }); 135 + return; 136 + } 137 + } else { 138 + ambient.deactivate(); 139 + } 127 140 localStorage.setItem('theme', theme); 128 141 this.applyTheme(theme); 129 142 } ··· 136 149 root.classList.remove('theme-dark', 'theme-light'); 137 150 138 151 let effectiveTheme: 'dark' | 'light'; 139 - if (theme === 'system') { 152 + if (theme === 'live') { 153 + effectiveTheme = 'dark'; 154 + } else if (theme === 'system') { 140 155 effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 141 156 } else { 142 157 effectiveTheme = theme; ··· 163 178 164 179 this.loading = true; 165 180 try { 166 - // preserve theme from localStorage (theme is client-side only) 167 - const storedTheme = localStorage.getItem('theme') as Theme | null; 168 - const currentTheme = storedTheme ?? this.data?.theme ?? DEFAULT_PREFERENCES.theme; 169 - 170 181 const response = await fetch(`${API_URL}/preferences/`, { 171 182 credentials: 'include' 172 183 }); 173 184 if (response.ok) { 174 185 const data = await response.json(); 175 - // auto_download_liked is stored locally since it's device-specific 186 + // theme comes from server (per-account), localStorage is just a flash-prevention cache 187 + const serverTheme = (data.theme as Theme) ?? DEFAULT_PREFERENCES.theme; 176 188 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; 177 189 this.data = { 178 190 accent_color: data.accent_color ?? null, 179 191 auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance, 180 192 allow_comments: data.allow_comments ?? DEFAULT_PREFERENCES.allow_comments, 181 193 hidden_tags: data.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags, 182 - theme: currentTheme, 194 + theme: serverTheme, 183 195 enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling, 184 196 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 185 197 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, ··· 190 202 terms_accepted_at: data.terms_accepted_at ?? null 191 203 }; 192 204 } else { 205 + // server error — fall back to localStorage cache 206 + const storedTheme = localStorage.getItem('theme') as Theme | null; 193 207 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; 194 - this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme, auto_download_liked: storedAutoDownload }; 208 + this.data = { ...DEFAULT_PREFERENCES, theme: storedTheme ?? DEFAULT_PREFERENCES.theme, auto_download_liked: storedAutoDownload }; 195 209 } 196 - // apply theme after fetching 210 + // sync localStorage cache and apply 197 211 if (browser) { 212 + localStorage.setItem('theme', this.data.theme); 198 213 this.applyTheme(this.data.theme); 214 + if (this.data.theme === 'live') { 215 + ambient.activate(); 216 + } else { 217 + ambient.deactivate(); 218 + } 199 219 } 200 220 } catch (error) { 201 221 console.error('failed to fetch preferences:', error); 202 - // preserve theme on error too 222 + // network error — fall back to localStorage cache 203 223 const storedTheme = localStorage.getItem('theme') as Theme | null; 204 224 this.data = { ...DEFAULT_PREFERENCES, theme: storedTheme ?? DEFAULT_PREFERENCES.theme }; 205 225 } finally {
+4 -3
frontend/src/routes/+layout.svelte
··· 66 66 // if authenticated, fetch preferences and reconnect to active jam or personal queue 67 67 if (auth.isAuthenticated) { 68 68 await preferences.initialize(); 69 - ambient.initialize(auth.user?.did); 70 69 71 70 // check for active jam first — if rejoining, jam owns the queue state 72 71 let joinedJam = false; ··· 321 320 } 322 321 323 322 // apply saved theme from localStorage 324 - const savedTheme = localStorage.getItem('theme') as 'dark' | 'light' | 'system' | null; 323 + const savedTheme = localStorage.getItem('theme') as 'dark' | 'light' | 'system' | 'live' | null; 325 324 if (savedTheme) { 326 325 preferences.applyTheme(savedTheme); 327 326 } else { ··· 429 428 // apply theme 430 429 const savedTheme = localStorage.getItem('theme') || 'dark'; 431 430 let effectiveTheme = savedTheme; 432 - if (savedTheme === 'system') { 431 + if (savedTheme === 'live') { 432 + effectiveTheme = 'dark'; 433 + } else if (savedTheme === 'system') { 433 434 effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 434 435 } 435 436 root.classList.add('theme-' + effectiveTheme);
+19 -33
frontend/src/routes/settings/+page.svelte
··· 38 38 let loadingTokens = $state(false); 39 39 let revokingToken = $state<string | null>(null); 40 40 41 - // ambient live state 42 - let ambientEnabled = $derived(ambient.enabled); 41 + // ambient live state (for display when live theme is active) 43 42 let ambientGradient = $derived(ambient.gradient); 44 43 let ambientLoading = $derived(ambient.loading); 45 44 let ambientError = $derived(ambient.error); 46 45 let ambientCondition = $derived(ambient.conditionLabel); 47 46 48 - async function toggleAmbient(enabled: boolean) { 49 - if (enabled) { 50 - await ambient.enable(); 51 - } else { 52 - ambient.disable(); 53 - } 54 - } 55 - 56 47 const presetColors = [ 57 48 { name: 'blue', value: '#6a9fff' }, 58 49 { name: 'purple', value: '#a78bfa' }, ··· 65 56 const themes: { value: Theme; label: string; icon: string }[] = [ 66 57 { value: 'dark', label: 'dark', icon: 'moon' }, 67 58 { value: 'light', label: 'light', icon: 'sun' }, 68 - { value: 'system', label: 'auto', icon: 'auto' } 59 + { value: 'system', label: 'auto', icon: 'auto' }, 60 + { value: 'live', label: 'live', icon: 'live' } 69 61 ]; 70 62 71 63 onMount(async () => { ··· 448 440 {#each themes as theme} 449 441 <button 450 442 class="theme-btn" 451 - class:active={currentTheme === theme.value && !ambientEnabled} 452 - onclick={() => { if (ambientEnabled) ambient.disable(); selectTheme(theme.value); }} 453 - title={theme.label} 443 + class:live-btn={theme.icon === 'live'} 444 + class:active={currentTheme === theme.value} 445 + class:live-loading={theme.icon === 'live' && ambientLoading} 446 + onclick={() => selectTheme(theme.value)} 447 + title={theme.icon === 'live' ? (currentTheme === 'live' ? ambientCondition ?? 'live' : 'live') : theme.label} 454 448 > 455 - {#if theme.icon === 'moon'} 449 + {#if theme.icon === 'live'} 450 + {#if currentTheme === 'live' && ambientGradient} 451 + <div class="live-orb-inline" style="background: {ambientGradient}"></div> 452 + {:else if ambientLoading} 453 + <div class="live-orb-inline live-orb-loading"></div> 454 + {:else} 455 + <svg viewBox="0 0 24 24" fill="currentColor" opacity="0.3"> 456 + <circle cx="12" cy="12" r="9" /> 457 + </svg> 458 + {/if} 459 + {:else if theme.icon === 'moon'} 456 460 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 457 461 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 458 462 </svg> ··· 471 475 <span>{theme.label}</span> 472 476 </button> 473 477 {/each} 474 - <button 475 - class="theme-btn live-btn" 476 - class:active={ambientEnabled} 477 - class:live-loading={ambientLoading} 478 - onclick={() => ambientEnabled ? ambient.disable() : toggleAmbient(true)} 479 - title={ambientEnabled ? ambientCondition ?? 'live' : '?'} 480 - > 481 - {#if ambientEnabled && ambientGradient} 482 - <div class="live-orb-inline" style="background: {ambientGradient}"></div> 483 - {:else if ambientLoading} 484 - <div class="live-orb-inline live-orb-loading"></div> 485 - {:else} 486 - <svg viewBox="0 0 24 24" fill="currentColor" opacity="0.3"> 487 - <circle cx="12" cy="12" r="9" /> 488 - </svg> 489 - {/if} 490 - <span>{ambientEnabled ? 'live' : '?'}</span> 491 - </button> 492 478 </div> 493 479 </div> 494 480