audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: homepage feed switcher — toggle between latest and for-you (#1282)

adds a feed mode toggle to the homepage's main infinite-scroll section.
authenticated users with engagement history see a clickable toggle
(same style as the top tracks period toggle) to switch between "latest
tracks" and "for you". unauthenticated users or those without enough
engagement data see no toggle — identical to today.

- new ForYouCache state module ($lib/for-you.svelte.ts) mirroring
TracksCache's interface but hitting /for-you/
- feed mode persisted to localStorage
- tag filters hidden when viewing for-you (backend handles hidden tags)
- infinite scroll dispatches to the active cache's fetchMore()

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6 (1M context)
and committed by
GitHub
793ca5b7 1e7e99e7

+249 -22
+71
docs/internal/plans/2026-04-12-homepage-feed-switcher.md
··· 1 + # plan: homepage feed switcher 2 + 3 + **date**: 2026-04-12 4 + 5 + ## goal 6 + 7 + the main infinite-scroll feed on the homepage (currently hardcoded to "latest tracks") becomes toggleable between "latest" and "for you". the toggle only appears for authenticated users whose for-you feed returns data. unauthenticated users see "latest tracks" exactly as today — no toggle, no change. 8 + 9 + ## current state 10 + 11 + - homepage feed section: heading "latest tracks" (clickable to refresh), `TagFilter` + `HiddenTagsFilter`, infinite scroll via `TracksCache` singleton 12 + - `TracksCache` (`$lib/tracks.svelte.ts`): global class with `tracks`, `loading`, `loadingMore`, `hasMore`, `nextCursor`, `activeTags`. fetches `/tracks/`, persists to localStorage, supports tag filtering and cursor pagination 13 + - `/for-you` page: standalone route with its own fetch logic, auth gating, cold-start messaging, infinite scroll. fetches `/for-you/` with cursor pagination. no tag filtering (backend applies hidden-tag filtering server-side) 14 + - top tracks period toggle: `cyclePeriod()` skips empty periods — the precedent for "don't show options that have no data" 15 + 16 + ## not doing 17 + 18 + - backend tag filtering on `/for-you/` — it already respects hidden tags server-side; active tag selection is a "latest" concept 19 + - removing the standalone `/for-you` route — it stays as a deep link 20 + - "queue all" button on the homepage feed section 21 + - cold-start messaging on the homepage — if for-you returns empty, the option simply doesn't appear 22 + - adding more feed modes (easy to extend later — just add to the modes array) 23 + 24 + ## phases 25 + 26 + ### phase 1: ForYouCache state module 27 + 28 + **changes**: 29 + - new `frontend/src/lib/for-you.svelte.ts` — `ForYouCache` class following `TracksCache` pattern: 30 + - `tracks: Track[]`, `loading`, `loadingMore`, `hasMore`, `nextCursor`, `coldStart` 31 + - `fetch(force?)` → `GET /for-you/` with credentials 32 + - `fetchMore()` → cursor-based pagination 33 + - `invalidate()` → reset state 34 + - no localStorage persistence (scores drift between requests anyway) 35 + - no tag filtering (not applicable) 36 + - exported singleton: `export const forYouCache = new ForYouCache()` 37 + 38 + **success criteria**: 39 + - [ ] `just frontend check` passes 40 + - [ ] module exports `forYouCache` with `fetch`, `fetchMore`, `invalidate` 41 + 42 + ### phase 2: homepage feed toggle 43 + 44 + **changes**: 45 + - `frontend/src/routes/+page.svelte`: 46 + - add `feedMode` state: `'latest' | 'for-you'`, persisted to localStorage key `feedMode` 47 + - on mount (for authenticated users only): probe `forYouCache.fetch()` — if it returns tracks, the for-you option is available. if empty, `feedMode` locks to `'latest'` and no toggle renders 48 + - the heading becomes: `latest tracks` or `for you`, with a toggle button (same `period-toggle` CSS class) showing the *other* option name — clicking swaps the feed mode 49 + - derive `tracks`, `loading`, `loadingMore`, `hasMore` from whichever cache is active based on `feedMode` 50 + - the `$effect` for `IntersectionObserver` calls `tracksCache.fetchMore()` or `forYouCache.fetchMore()` based on mode 51 + - `TagFilter` and `HiddenTagsFilter` only render when `feedMode === 'latest'` 52 + - the clickable-heading refresh behavior: in latest mode refreshes `tracksCache`, in for-you mode refreshes `forYouCache` 53 + - unauthenticated users: no toggle, no probe, "latest tracks" only — identical to today 54 + 55 + **success criteria**: 56 + - [ ] `just frontend check` passes 57 + - [ ] unauthenticated: homepage looks identical to today 58 + - [ ] authenticated with engagement: toggle appears, switching feeds swaps data source + hides/shows tag filters 59 + - [ ] authenticated with no engagement: no toggle, just "latest tracks" 60 + - [ ] feed mode persists across page reloads via localStorage 61 + - [ ] infinite scroll works for both feeds 62 + - [ ] switching feeds resets scroll position / pagination 63 + 64 + ## testing 65 + 66 + - unauth visit: no toggle visible, "latest tracks" feed works as before 67 + - auth visit (user with likes/playlist-adds): toggle appears, both feeds load, infinite scroll works in both 68 + - auth visit (fresh user, no engagement): for-you probe returns empty, no toggle shown 69 + - switch feeds mid-scroll: data resets cleanly, no stale tracks from previous feed 70 + - tag filter state: tags applied in "latest" mode, switching to "for you" hides filters, switching back restores them 71 + - localStorage persistence: reload page, feed mode is remembered
+78
frontend/src/lib/for-you.svelte.ts
··· 1 + import { API_URL } from './config'; 2 + import type { Track } from './types'; 3 + 4 + interface ForYouApiResponse { 5 + tracks: Track[]; 6 + next_cursor: string | null; 7 + has_more: boolean; 8 + cold_start: boolean; 9 + } 10 + 11 + class ForYouCache { 12 + tracks = $state<Track[]>([]); 13 + loading = $state(false); 14 + loadingMore = $state(false); 15 + nextCursor = $state<string | null>(null); 16 + hasMore = $state(false); 17 + coldStart = $state(false); 18 + 19 + async fetch(force = false): Promise<void> { 20 + if (!force && this.loading) return; 21 + 22 + this.loading = true; 23 + try { 24 + const response = await fetch(`${API_URL}/for-you/`, { 25 + credentials: 'include' 26 + }); 27 + if (!response.ok) { 28 + this.tracks = []; 29 + this.hasMore = false; 30 + return; 31 + } 32 + const data: ForYouApiResponse = await response.json(); 33 + this.tracks = data.tracks; 34 + this.nextCursor = data.next_cursor; 35 + this.hasMore = data.has_more; 36 + this.coldStart = data.cold_start; 37 + } catch (e) { 38 + console.error('failed to fetch for-you feed:', e); 39 + this.tracks = []; 40 + this.hasMore = false; 41 + } finally { 42 + this.loading = false; 43 + } 44 + } 45 + 46 + async fetchMore(): Promise<void> { 47 + if (this.loadingMore || this.loading || !this.hasMore || !this.nextCursor) return; 48 + 49 + this.loadingMore = true; 50 + try { 51 + const url = new URL(`${API_URL}/for-you/`); 52 + url.searchParams.set('cursor', this.nextCursor); 53 + 54 + const response = await fetch(url.toString(), { 55 + credentials: 'include' 56 + }); 57 + if (!response.ok) return; 58 + const data: ForYouApiResponse = await response.json(); 59 + 60 + this.tracks = [...this.tracks, ...data.tracks]; 61 + this.nextCursor = data.next_cursor; 62 + this.hasMore = data.has_more; 63 + } catch (e) { 64 + console.error('failed to fetch more for-you tracks:', e); 65 + } finally { 66 + this.loadingMore = false; 67 + } 68 + } 69 + 70 + invalidate(): void { 71 + this.tracks = []; 72 + this.nextCursor = null; 73 + this.hasMore = false; 74 + this.coldStart = false; 75 + } 76 + } 77 + 78 + export const forYouCache = new ForYouCache();
+99 -21
frontend/src/routes/+page.svelte
··· 11 11 import { player } from '$lib/player.svelte'; 12 12 import { queue } from '$lib/queue.svelte'; 13 13 import { tracksCache, fetchTopTracks } from '$lib/tracks.svelte'; 14 + import { forYouCache } from '$lib/for-you.svelte'; 14 15 import { networkArtistsCache } from '$lib/network-artists.svelte'; 15 16 import type { Track } from '$lib/types'; 16 17 import { auth } from '$lib/auth.svelte'; ··· 24 25 hasAttemptedRefresh 25 26 } from '$lib/avatar-refresh.svelte'; 26 27 27 - // use cached tracks 28 - let tracks = $derived(tracksCache.tracks); 29 - let loadingTracks = $derived(tracksCache.loading); 30 - let loadingMore = $derived(tracksCache.loadingMore); 31 - let hasMore = $derived(tracksCache.hasMore); 28 + // feed mode state 29 + type FeedMode = 'latest' | 'for-you'; 30 + let feedMode = $state<FeedMode>( 31 + (typeof window !== 'undefined' && localStorage.getItem('feedMode') as FeedMode) || 'latest' 32 + ); 33 + let forYouAvailable = $state(false); 34 + 35 + // use active feed cache based on mode 36 + let tracks = $derived(feedMode === 'for-you' ? forYouCache.tracks : tracksCache.tracks); 37 + let loadingTracks = $derived(feedMode === 'for-you' ? forYouCache.loading : tracksCache.loading); 38 + let loadingMore = $derived(feedMode === 'for-you' ? forYouCache.loadingMore : tracksCache.loadingMore); 39 + let hasMore = $derived(feedMode === 'for-you' ? forYouCache.hasMore : tracksCache.hasMore); 32 40 let hasTracks = $derived(tracks.length > 0); 33 41 let initialLoad = $state(true); 34 42 ··· 132 140 } 133 141 }); 134 142 143 + // probe for-you feed availability when authenticated 144 + $effect(() => { 145 + if (auth.isAuthenticated) { 146 + forYouCache.fetch().then(() => { 147 + forYouAvailable = forYouCache.tracks.length > 0; 148 + // if user had for-you selected but it's now empty, fall back to latest 149 + if (feedMode === 'for-you' && !forYouAvailable) { 150 + feedMode = 'latest'; 151 + if (typeof window !== 'undefined') { 152 + localStorage.setItem('feedMode', 'latest'); 153 + } 154 + } 155 + }); 156 + } else { 157 + forYouAvailable = false; 158 + if (feedMode === 'for-you') { 159 + feedMode = 'latest'; 160 + if (typeof window !== 'undefined') { 161 + localStorage.setItem('feedMode', 'latest'); 162 + } 163 + } 164 + } 165 + }); 166 + 135 167 // set up IntersectionObserver for infinite scroll 136 168 $effect(() => { 137 169 if (!sentinelElement) return; ··· 140 172 (entries) => { 141 173 const entry = entries[0]; 142 174 if (entry.isIntersecting && hasMore && !loadingMore && !loadingTracks) { 143 - tracksCache.fetchMore(); 175 + if (feedMode === 'for-you') { 176 + forYouCache.fetchMore(); 177 + } else { 178 + tracksCache.fetchMore(); 179 + } 144 180 } 145 181 }, 146 182 { ··· 173 209 window.location.href = '/'; 174 210 } 175 211 176 - async function refreshTracks() { 177 - await tracksCache.fetch(true); // force refresh 212 + function toggleFeed() { 213 + const next: FeedMode = feedMode === 'latest' ? 'for-you' : 'latest'; 214 + feedMode = next; 215 + if (typeof window !== 'undefined') { 216 + localStorage.setItem('feedMode', next); 217 + } 218 + // fetch if the target cache is empty 219 + if (next === 'for-you' && forYouCache.tracks.length === 0) { 220 + forYouCache.fetch(); 221 + } else if (next === 'latest' && tracksCache.tracks.length === 0) { 222 + tracksCache.fetch(); 223 + } 224 + } 225 + 226 + function refreshFeed() { 227 + if (feedMode === 'for-you') { 228 + forYouCache.fetch(true); 229 + } else { 230 + tracksCache.fetch(true); 231 + } 178 232 } 179 233 </script> 180 234 ··· 215 269 top tracks <button class="period-toggle" onclick={cyclePeriod}>{periodLabel}</button> 216 270 </h2> 217 271 <div class="top-tracks-grid"> 218 - {#each topTracks as track, i} 272 + {#each topTracks as track, i (track.id)} 219 273 <TrackCard 220 274 {track} 221 275 index={i} ··· 233 287 <section class="network-artists" transition:fade={{ duration: 200 }}> 234 288 <h2>artists you know</h2> 235 289 <div class="artist-grid"> 236 - {#each networkArtists as artist} 290 + {#each networkArtists as artist (artist.did)} 237 291 {@const refreshedUrl = getRefreshedAvatar(artist.did)} 238 292 {@const displayUrl = refreshedUrl ?? artist.avatar_url} 239 293 <a href="/u/{artist.handle}" class="artist-card"> ··· 267 321 <button 268 322 type="button" 269 323 class="clickable-heading" 270 - onclick={refreshTracks} 324 + onclick={refreshFeed} 271 325 onkeydown={(event) => { 272 326 if (event.key === 'Enter' || event.key === ' ') { 273 327 event.preventDefault(); 274 - refreshTracks(); 328 + refreshFeed(); 275 329 } 276 330 }} 277 331 title="click to refresh" 278 332 > 279 - latest tracks 333 + {feedMode === 'for-you' ? 'for you' : 'latest tracks'} 280 334 </button> 335 + {#if auth.isAuthenticated && forYouAvailable} 336 + <button class="feed-toggle" onclick={toggleFeed}> 337 + {feedMode === 'for-you' ? 'latest' : 'for you'} 338 + </button> 339 + {/if} 281 340 </h2> 282 - </div> 283 - <div class="filter-row"> 284 - <TagFilter 285 - onTagsChange={(tags) => tracksCache.setTags(tags)} 286 - hiddenTags={preferences.hiddenTags} 287 - /> 288 - <HiddenTagsFilter /> 289 341 </div> 342 + {#if feedMode === 'latest'} 343 + <div class="filter-row"> 344 + <TagFilter 345 + onTagsChange={(tags) => tracksCache.setTags(tags)} 346 + hiddenTags={preferences.hiddenTags} 347 + /> 348 + <HiddenTagsFilter /> 349 + </div> 350 + {/if} 290 351 {#if showLoading} 291 352 <div class="loading-container"> 292 353 <WaveLoading size="lg" message="loading tracks..." /> ··· 295 356 <p class="empty">no tracks yet</p> 296 357 {:else} 297 358 <div class="track-list"> 298 - {#each tracks as track, i} 359 + {#each tracks as track, i (track.id)} 299 360 <TrackItem 300 361 {track} 301 362 index={i} ··· 353 414 } 354 415 355 416 .period-toggle:hover { 417 + opacity: 0.7; 418 + } 419 + 420 + .feed-toggle { 421 + background: transparent; 422 + border: none; 423 + padding: 0; 424 + font: inherit; 425 + font-size: var(--text-base); 426 + font-weight: 400; 427 + color: var(--accent); 428 + cursor: pointer; 429 + transition: opacity 0.15s; 430 + user-select: none; 431 + } 432 + 433 + .feed-toggle:hover { 356 434 opacity: 0.7; 357 435 } 358 436
+1 -1
loq.toml
··· 232 232 233 233 [[rules]] 234 234 path = "frontend/src/routes/+page.svelte" 235 - max_lines = 528 235 + max_lines = 606 236 236 237 237 [[rules]] 238 238 path = "frontend/src/lib/components/AlbumUploadForm.svelte"