WIP: My personal website
0

Configure Feed

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

changes for freetime/now playing

+116 -33
+9 -1
src/lib/components/FreeTime.svelte
··· 64 64 Read 65 65 </a> 66 66 </div> 67 + <a class="mt-3 link font-urbanist text-sm link-accent" href="/history/books"> 68 + Recent books → 69 + </a> 67 70 </div> 68 71 </div> 69 72 </div> ··· 108 111 </div> 109 112 {/if} 110 113 <div class="flex flex-col"> 111 - <span class="badge font-urbanist badge-secondary">Now listening</span> 114 + <span class="badge font-urbanist badge-secondary"> 115 + {nowPlaying.playedTime ? 'Last listened' : 'Now listening'} 116 + </span> 112 117 <h2 class="mt-2 card-title font-urbanist text-2xl font-black"> 113 118 {nowPlaying.trackName} 114 119 </h2> ··· 128 133 </a> 129 134 </div> 130 135 {/if} 136 + <a class="mt-3 link font-urbanist text-sm link-accent" href="/history/music"> 137 + Recent music → 138 + </a> 131 139 </div> 132 140 </div> 133 141 </div>
+32 -13
src/lib/components/NavBar.svelte
··· 15 15 { name: 'Writings', href: '/#writings' }, 16 16 { 17 17 name: 'Free Time', 18 + href: '/#freetime', 18 19 children: [ 20 + { name: 'Currently', href: '/#freetime' }, 19 21 { name: 'Music', href: '/history/music' }, 20 22 { name: 'Books', href: '/history/books' } 21 23 ] ··· 47 49 }; 48 50 49 51 let active = $derived.by(() => { 50 - // if ($page.url.pathname; 51 52 const path = `${$page.url.pathname}${$page.url.hash}`; 52 - let test = path === '/' || path === '' ? '/#home' : path; 53 - console.log(test); 54 - return test; 53 + return path === '/' || path === '' ? '/#home' : path; 55 54 }); 56 55 57 56 function onActivate(href: string | undefined) { 58 57 if (href) active = href; 58 + } 59 + 60 + // A nav item is active when its own href matches, or — for dropdown parents like 61 + // "Free Time" — when any of its children (e.g. the history pages) is active. 62 + function isActive(item: NavItem): boolean { 63 + if (item.href && active === item.href) return true; 64 + return item.children?.some((child) => active === child.href) ?? false; 59 65 } 60 66 61 67 // onMount(() => { ··· 128 134 {#if item.children} 129 135 <li> 130 136 <details> 131 - <summary class="font-urbanist">{item.name}</summary> 137 + <summary class="font-urbanist {isActive(item) ? 'menu-active' : ''}"> 138 + {item.name} 139 + </summary> 132 140 <ul> 133 141 {#each item.children as child (child.name)} 134 - <li><a href={child.href} class="font-urbanist">{child.name}</a></li> 142 + <li> 143 + <a 144 + href={child.href} 145 + class="font-urbanist {active === child.href ? 'menu-active' : ''}" 146 + > 147 + {child.name} 148 + </a> 149 + </li> 135 150 {/each} 136 151 </ul> 137 152 </details> 138 153 </li> 139 154 {:else} 140 155 <li> 141 - <a href={item.href} class="font-urbanist">{item.name}</a> 156 + <a href={item.href} class="font-urbanist {isActive(item) ? 'menu-active' : ''}"> 157 + {item.name} 158 + </a> 142 159 </li> 143 160 {/if} 144 161 {/each} ··· 153 170 <nav class="menu menu-horizontal px-1"> 154 171 {#if item.children} 155 172 <div class="dropdown-hover dropdown"> 156 - <div 173 + <a 174 + href={item.href} 157 175 tabindex="0" 158 - role="button" 159 - class="btn rounded-full font-urbanist text-sm font-light btn-ghost" 176 + class="btn rounded-full font-urbanist text-sm font-light btn-ghost {isActive(item) 177 + ? 'bg-base-300' 178 + : ''}" 179 + onclick={() => onActivate(item.href)} 160 180 > 161 181 {item.name} 162 - </div> 182 + </a> 163 183 <!-- mt-0! removes daisyUI's default 8px margin so there's no hover 164 184 dead-gap between the trigger and the menu; p-2 keeps inner spacing. --> 165 185 <ul ··· 182 202 {:else} 183 203 <a 184 204 href={item.href} 185 - class="btn rounded-full font-urbanist text-sm font-light btn-ghost {active === 186 - item.href 205 + class="btn rounded-full font-urbanist text-sm font-light btn-ghost {isActive(item) 187 206 ? 'bg-base-300' 188 207 : ''}" 189 208 onclick={() => onActivate(item.href)}
+72 -19
src/lib/server/atproto/music.ts
··· 3 3 import type { AtUriString } from '@atproto/lex'; 4 4 import type { NowPlaying, RecentPlay } from '$lib/types'; 5 5 import { cache, MIN_MS } from '$lib/server/cache'; 6 - import { createClient, getRepoInfo, repoId } from './client'; 6 + import { createClient, getRepoInfo, repoId, type RepoInfo } from './client'; 7 7 import { resolveCoverArt } from './coverArt'; 8 8 9 9 const RECENT_PLAYS_LIMIT = 50; ··· 27 27 return results; 28 28 } 29 29 30 + /** 31 + * A teal status is only "live" until its expiry — which defaults to 10 minutes 32 + * past the start time when the record omits one. Past that, the actor isn't 33 + * actively listening and we should fall back to their last play. 34 + */ 35 + function isStatusLive(status: { time: string; expiry?: string }): boolean { 36 + const expiryMs = status.expiry 37 + ? Date.parse(status.expiry) 38 + : Date.parse(status.time) + 10 * MIN_MS; 39 + return Number.isFinite(expiryMs) && expiryMs > Date.now(); 40 + } 41 + 42 + /** Most recently listened track, shaped as a NowPlaying fallback (playedTime set). */ 43 + async function fetchLastPlay( 44 + fetch: typeof globalThis.fetch, 45 + repo: RepoInfo 46 + ): Promise<NowPlaying | null> { 47 + const client = createClient(repo.pds); 48 + // Fetch a small batch and pick the newest defensively. (This PDS returns 49 + // records newest-first by default; passing `reverse: true` returns none.) 50 + const res = await client.list(fm.teal.alpha.feed.play, { 51 + repo: repoId(repo.did), 52 + limit: 5 53 + }); 54 + const records = res.records as ReadonlyArray<{ value: PlayRecord }>; 55 + if (records.length === 0) return null; 56 + 57 + const value = [...records].sort((a, b) => 58 + (b.value.playedTime ?? '').localeCompare(a.value.playedTime ?? '') 59 + )[0].value; 60 + // History uses the release MBID directly — no MusicBrainz ISRC lookup. 61 + const coverArt = await resolveCoverArt(fetch, { 62 + releaseMbId: value.releaseMbId ?? null, 63 + releaseName: value.releaseName ?? null 64 + }); 65 + 66 + return { 67 + trackName: value.trackName, 68 + artists: value.artists.map((a) => a.artistName).join(', '), 69 + releaseName: value.releaseName ?? null, 70 + url: value.originUrl ?? null, 71 + coverArt, 72 + playedTime: value.playedTime ?? null 73 + }; 74 + } 75 + 30 76 export async function fetchNowPlaying(fetch: typeof globalThis.fetch): Promise<NowPlaying | null> { 31 77 const cached = cache.get('nowPlaying') as { value: NowPlaying | null } | null; 32 78 if (cached) return cached.value; ··· 36 82 37 83 try { 38 84 const client = createClient(repo.pds); 39 - const res = await client.get(fm.teal.alpha.actor.status, { 40 - repo: repoId(repo.did) 41 - }); 42 - const item = res.value.item; 85 + const status = await client 86 + .get(fm.teal.alpha.actor.status, { repo: repoId(repo.did) }) 87 + .then((res) => res.value) 88 + .catch(() => null); // no status record yet — treat as not currently listening 43 89 44 - const coverArt = await resolveCoverArt(fetch, { 45 - isrc: item.isrc ?? null, 46 - releaseMbId: item.releaseMbId ?? null, 47 - releaseName: item.releaseName ?? null, 48 - useIsrcLookup: true 49 - }); 50 - 51 - const nowPlaying: NowPlaying = { 52 - trackName: item.trackName, 53 - artists: item.artists.map((a) => a.artistName).join(', '), 54 - releaseName: item.releaseName ?? null, 55 - url: item.originUrl ?? null, 56 - coverArt 57 - }; 90 + let nowPlaying: NowPlaying | null; 91 + if (status && isStatusLive(status)) { 92 + const item = status.item; 93 + const coverArt = await resolveCoverArt(fetch, { 94 + isrc: item.isrc ?? null, 95 + releaseMbId: item.releaseMbId ?? null, 96 + releaseName: item.releaseName ?? null, 97 + useIsrcLookup: true 98 + }); 99 + nowPlaying = { 100 + trackName: item.trackName, 101 + artists: item.artists.map((a) => a.artistName).join(', '), 102 + releaseName: item.releaseName ?? null, 103 + url: item.originUrl ?? null, 104 + coverArt, 105 + playedTime: null 106 + }; 107 + } else { 108 + // Nothing playing right now — show the most recently listened track instead. 109 + nowPlaying = await fetchLastPlay(fetch, repo); 110 + } 58 111 59 112 cache.setWithExpiry('nowPlaying', { value: nowPlaying }, MIN_MS); 60 113 return nowPlaying;
+3
src/lib/types.ts
··· 25 25 releaseName: string | null; 26 26 url: string | null; 27 27 coverArt: string | null; 28 + // null when this is a live "now playing" status; set to the play's time when 29 + // we've fallen back to the most recently listened track instead. 30 + playedTime: string | null; 28 31 }; 29 32 30 33 export type RecentPlay = {