A social internet radio platform built on AT Protocol. atradio.fm
atproto radio
7

Configure Feed

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

feat(web): consume AppView XRPC + env setup

- appview client (lib/appview.ts) calling api.atradio.fm/xrpc/fm.atradio.*,
base from VITE_APPVIEW_URL.
- Public profiles (/profile/:actor) now read favorites/stations from the
AppView; own profile still reads from the PDS. Drop unused publicReader.
- .env.production (prod API), .env.example (reference); .env.local is
gitignored for local dev (points AppView at http://127.0.0.1:8080).

Tsiry Sandratraina (Jul 14, 2026, 10:13 PM +0300) 87f70190 c59131c6

+92 -36
+14
apps/web/.env.example
··· 1 + # atradio.fm web env reference (all optional; Vite bakes VITE_* in at build). 2 + # 3 + # Loading order (Vite): .env < .env.local < .env.<mode> < .env.<mode>.local 4 + # - dev: copy this to `.env.local` and set the local API URL. 5 + # - prod: values live in `.env.production` (committed). 6 + 7 + # AppView XRPC base (public profiles + discovery). 8 + # dev : http://127.0.0.1:8080 prod: https://api.atradio.fm 9 + VITE_APPVIEW_URL=http://127.0.0.1:8080 10 + 11 + # Media proxies. In dev these default to Vite's built-in proxy (leave unset); 12 + # in prod they point at the deployed API. 13 + # VITE_TUNEIN_PROXY=https://api.atradio.fm/api/tunein 14 + # VITE_ICY_PROXY=https://api.atradio.fm/api/icy
+2
apps/web/.env.production
··· 2 2 # Point the TuneIn + ICY media proxies at the deployed API (CORS is open there). 3 3 VITE_TUNEIN_PROXY=https://api.atradio.fm/api/tunein 4 4 VITE_ICY_PROXY=https://api.atradio.fm/api/icy 5 + # AppView XRPC base (discovery + optional public-profile reads). 6 + VITE_APPVIEW_URL=https://api.atradio.fm
+62
apps/web/src/lib/appview.ts
··· 1 + import type { StationListOutput, StationView } from "@atradio/lexicons"; 2 + 3 + /** 4 + * Client for the atradio.fm AppView XRPC API (apps/api). Reads the indexed, 5 + * aggregated data (public profiles by handle/did, discovery feeds). In dev, 6 + * set VITE_APPVIEW_URL to your locally-running API (e.g. http://127.0.0.1:8080). 7 + */ 8 + const BASE = (import.meta.env.VITE_APPVIEW_URL ?? "https://api.atradio.fm").replace( 9 + /\/$/, 10 + "", 11 + ); 12 + 13 + export interface ListQuery { 14 + limit?: number; 15 + cursor?: string; 16 + /** free-text filter (name/genre) */ 17 + q?: string; 18 + /** radio-browser | tunein | custom */ 19 + source?: string; 20 + /** recent | name | -name */ 21 + sort?: string; 22 + } 23 + 24 + async function listQuery( 25 + nsid: string, 26 + actor: string, 27 + opts: ListQuery = {}, 28 + ): Promise<StationListOutput> { 29 + const params = new URLSearchParams({ actor }); 30 + if (opts.limit != null) params.set("limit", String(opts.limit)); 31 + if (opts.cursor) params.set("cursor", opts.cursor); 32 + if (opts.q) params.set("q", opts.q); 33 + if (opts.source) params.set("source", opts.source); 34 + if (opts.sort) params.set("sort", opts.sort); 35 + const res = await fetch(`${BASE}/xrpc/${nsid}?${params}`, { 36 + headers: { Accept: "application/json" }, 37 + }); 38 + if (!res.ok) throw new Error(`${nsid}: ${res.status}`); 39 + return (await res.json()) as StationListOutput; 40 + } 41 + 42 + /** A user's favorited stations (from the index). */ 43 + export const getFavorites = (actor: string, opts?: ListQuery) => 44 + listQuery("fm.atradio.getFavorites", actor, opts); 45 + 46 + /** A user's entered stations (from the index). */ 47 + export const getStations = (actor: string, opts?: ListQuery) => 48 + listQuery("fm.atradio.getStations", actor, opts); 49 + 50 + async function feed(nsid: string, limit = 30): Promise<StationView[]> { 51 + const res = await fetch(`${BASE}/xrpc/${nsid}?limit=${limit}`, { 52 + headers: { Accept: "application/json" }, 53 + }); 54 + if (!res.ok) throw new Error(`${nsid}: ${res.status}`); 55 + const data = (await res.json()) as { items: StationView[] }; 56 + return data.items; 57 + } 58 + 59 + export const getRecentStations = (limit?: number) => 60 + feed("fm.atradio.getRecentStations", limit); 61 + export const getPopularStations = (limit?: number) => 62 + feed("fm.atradio.getPopularStations", limit);
-29
apps/web/src/lib/atproto/publicReader.ts
··· 1 - import { Client, simpleFetchHandler } from "@atcute/client"; 2 - import type { Did } from "@atcute/lexicons"; 3 - import { resolveActor } from "./resolver"; 4 - import { listFavorites, listStations, type StoredStation } from "./records"; 5 - 6 - export interface PublicUser { 7 - did: Did; 8 - handle: string; 9 - favorites: StoredStation[]; 10 - stations: StoredStation[]; 11 - } 12 - 13 - /** 14 - * Read any user's atradio records straight from their PDS (public, no auth). 15 - * `com.atproto.repo.listRecords` is a public endpoint, so this works for any 16 - * actor and keeps the profile page independent of our AppView being online. 17 - */ 18 - export async function readPublicUser(actor: string): Promise<PublicUser> { 19 - const resolved = await resolveActor(actor); 20 - const did = resolved.did as Did; 21 - const client = new Client({ 22 - handler: simpleFetchHandler({ service: resolved.pds }), 23 - }); 24 - const [favorites, stations] = await Promise.all([ 25 - listFavorites(client, did), 26 - listStations(client, did), 27 - ]); 28 - return { did, handle: resolved.handle, favorites, stations }; 29 - }
+13 -7
apps/web/src/routes/ProfilePage.tsx
··· 20 20 import { addStationOpenAtom } from "@/atoms/ui"; 21 21 import { useAuth } from "@/hooks/useAuth"; 22 22 import { getProfile, type ActorProfile } from "@/lib/atproto/profile"; 23 - import { readPublicUser } from "@/lib/atproto/publicReader"; 23 + import * as appview from "@/lib/appview"; 24 + import { infoToStation } from "@atradio/lexicons"; 24 25 import { StationGrid } from "@/components/StationGrid"; 25 26 import { EmptyState } from "@/components/EmptyState"; 26 27 import type { Station } from "@/lib/types"; ··· 87 88 queryKey: ["profile", actor], 88 89 queryFn: () => getProfile(actor), 89 90 }); 90 - const dataQuery = useQuery({ 91 - queryKey: ["public-user", actor], 92 - queryFn: () => readPublicUser(actor), 91 + // Read the indexed data from the AppView XRPC (apps/api). 92 + const favQuery = useQuery({ 93 + queryKey: ["appview-favorites", actor], 94 + queryFn: () => appview.getFavorites(actor, { limit: 100 }), 95 + }); 96 + const staQuery = useQuery({ 97 + queryKey: ["appview-stations", actor], 98 + queryFn: () => appview.getStations(actor, { limit: 100 }), 93 99 }); 94 100 95 - if (profileQuery.isLoading || dataQuery.isLoading) { 101 + if (profileQuery.isLoading) { 96 102 return <CenteredSpinner />; 97 103 } 98 104 if (profileQuery.isError || !profileQuery.data) { ··· 108 114 return ( 109 115 <ProfileView 110 116 profile={profileQuery.data} 111 - favorites={(dataQuery.data?.favorites ?? []).map((f) => f.station)} 112 - stations={(dataQuery.data?.stations ?? []).map((s) => s.station)} 117 + favorites={(favQuery.data?.items ?? []).map((v) => infoToStation(v.station))} 118 + stations={(staQuery.data?.items ?? []).map((v) => infoToStation(v.station))} 113 119 /> 114 120 ); 115 121 }
+1
apps/web/src/vite-env.d.ts
··· 3 3 interface ImportMetaEnv { 4 4 readonly VITE_TUNEIN_PROXY?: string; 5 5 readonly VITE_ICY_PROXY?: string; 6 + readonly VITE_APPVIEW_URL?: string; 6 7 } 7 8 8 9 interface ImportMeta {