music on atproto
plyr.fm
1// user preferences state management
2import { browser } from '$app/environment';
3import { API_URL } from '$lib/config';
4import { auth } from '$lib/auth.svelte';
5
6export type Theme = 'dark' | 'light' | 'system';
7
8export interface UiSettings {
9 background_image_url?: string;
10 background_tile?: boolean;
11 use_playing_artwork_as_background?: boolean;
12}
13
14export interface Preferences {
15 accent_color: string | null;
16 auto_advance: boolean;
17 allow_comments: boolean;
18 hidden_tags: string[];
19 theme: Theme;
20 enable_teal_scrobbling: boolean;
21 teal_needs_reauth: boolean;
22 show_sensitive_artwork: boolean;
23 show_liked_on_profile: boolean;
24 support_url: string | null;
25 ui_settings: UiSettings;
26 auto_download_liked: boolean;
27}
28
29const DEFAULT_PREFERENCES: Preferences = {
30 accent_color: null,
31 auto_advance: true,
32 allow_comments: true,
33 hidden_tags: ['ai'],
34 theme: 'dark',
35 enable_teal_scrobbling: false,
36 teal_needs_reauth: false,
37 show_sensitive_artwork: false,
38 show_liked_on_profile: false,
39 support_url: null,
40 ui_settings: {},
41 auto_download_liked: false
42};
43
44class PreferencesManager {
45 data = $state<Preferences | null>(null);
46 loading = $state(false);
47 private initialized = false;
48
49 get loaded(): boolean {
50 return this.data !== null;
51 }
52
53 get hiddenTags(): string[] {
54 return this.data?.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags;
55 }
56
57 get accentColor(): string | null {
58 return this.data?.accent_color ?? null;
59 }
60
61 get autoAdvance(): boolean {
62 return this.data?.auto_advance ?? DEFAULT_PREFERENCES.auto_advance;
63 }
64
65 get allowComments(): boolean {
66 return this.data?.allow_comments ?? DEFAULT_PREFERENCES.allow_comments;
67 }
68
69 get theme(): Theme {
70 return this.data?.theme ?? DEFAULT_PREFERENCES.theme;
71 }
72
73 get enableTealScrobbling(): boolean {
74 return this.data?.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling;
75 }
76
77 get tealNeedsReauth(): boolean {
78 return this.data?.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth;
79 }
80
81 get showSensitiveArtwork(): boolean {
82 return this.data?.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork;
83 }
84
85 get showLikedOnProfile(): boolean {
86 return this.data?.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile;
87 }
88
89 get supportUrl(): string | null {
90 return this.data?.support_url ?? DEFAULT_PREFERENCES.support_url;
91 }
92
93 get uiSettings(): UiSettings {
94 return this.data?.ui_settings ?? DEFAULT_PREFERENCES.ui_settings;
95 }
96
97 get autoDownloadLiked(): boolean {
98 return this.data?.auto_download_liked ?? DEFAULT_PREFERENCES.auto_download_liked;
99 }
100
101 setAutoDownloadLiked(enabled: boolean): void {
102 if (browser) {
103 localStorage.setItem('autoDownloadLiked', enabled ? '1' : '0');
104 }
105 if (this.data) {
106 this.data = { ...this.data, auto_download_liked: enabled };
107 }
108 }
109
110 setTheme(theme: Theme): void {
111 if (browser) {
112 localStorage.setItem('theme', theme);
113 this.applyTheme(theme);
114 }
115 this.update({ theme });
116 }
117
118 applyTheme(theme: Theme): void {
119 if (!browser) return;
120 const root = document.documentElement;
121 root.classList.remove('theme-dark', 'theme-light');
122
123 let effectiveTheme: 'dark' | 'light';
124 if (theme === 'system') {
125 effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
126 } else {
127 effectiveTheme = theme;
128 }
129 root.classList.add(`theme-${effectiveTheme}`);
130 }
131
132 async initialize(): Promise<void> {
133 if (!browser || this.initialized || this.loading) return;
134 this.initialized = true;
135 await this.fetch();
136 }
137
138 async fetch(): Promise<void> {
139 if (!browser || !auth.isAuthenticated) return;
140
141 this.loading = true;
142 try {
143 // preserve theme from localStorage (theme is client-side only)
144 const storedTheme = localStorage.getItem('theme') as Theme | null;
145 const currentTheme = storedTheme ?? this.data?.theme ?? DEFAULT_PREFERENCES.theme;
146
147 const response = await fetch(`${API_URL}/preferences/`, {
148 credentials: 'include'
149 });
150 if (response.ok) {
151 const data = await response.json();
152 // auto_download_liked is stored locally since it's device-specific
153 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1';
154 this.data = {
155 accent_color: data.accent_color ?? null,
156 auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance,
157 allow_comments: data.allow_comments ?? DEFAULT_PREFERENCES.allow_comments,
158 hidden_tags: data.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags,
159 theme: currentTheme,
160 enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling,
161 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth,
162 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork,
163 show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile,
164 support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url,
165 ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings,
166 auto_download_liked: storedAutoDownload
167 };
168 } else {
169 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1';
170 this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme, auto_download_liked: storedAutoDownload };
171 }
172 // apply theme after fetching
173 if (browser) {
174 this.applyTheme(this.data.theme);
175 }
176 } catch (error) {
177 console.error('failed to fetch preferences:', error);
178 // preserve theme on error too
179 const storedTheme = localStorage.getItem('theme') as Theme | null;
180 this.data = { ...DEFAULT_PREFERENCES, theme: storedTheme ?? DEFAULT_PREFERENCES.theme };
181 } finally {
182 this.loading = false;
183 }
184 }
185
186 async update(updates: Partial<Preferences>): Promise<void> {
187 if (!browser || !auth.isAuthenticated) return;
188
189 // optimistic update
190 if (this.data) {
191 this.data = { ...this.data, ...updates };
192 }
193
194 try {
195 await fetch(`${API_URL}/preferences/`, {
196 method: 'POST',
197 headers: { 'Content-Type': 'application/json' },
198 credentials: 'include',
199 body: JSON.stringify(updates)
200 });
201 } catch (error) {
202 console.error('failed to save preferences:', error);
203 // revert on error by refetching
204 await this.fetch();
205 }
206 }
207
208 async updateUiSettings(updates: Partial<UiSettings>): Promise<void> {
209 if (!browser || !auth.isAuthenticated) return;
210
211 // optimistic update - merge with existing
212 if (this.data) {
213 this.data = {
214 ...this.data,
215 ui_settings: { ...this.data.ui_settings, ...updates }
216 };
217 }
218
219 try {
220 const response = await fetch(`${API_URL}/preferences/`, {
221 method: 'POST',
222 headers: { 'Content-Type': 'application/json' },
223 credentials: 'include',
224 body: JSON.stringify({ ui_settings: updates })
225 });
226 if (!response.ok) {
227 console.error('failed to save ui settings:', response.status);
228 await this.fetch();
229 }
230 } catch (error) {
231 console.error('failed to save ui settings:', error);
232 await this.fetch();
233 }
234 }
235
236 clear(): void {
237 this.data = null;
238 this.initialized = false;
239 }
240}
241
242export const preferences = new PreferencesManager();