Commits
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "CLI" button in the navbar that opens a modal with copyable
atradio.fm command-line install instructions for macOS and Linux
(Homebrew, Nix, Cargo, apt, dnf). Each command has a copy icon.
The Linux build failed because rockbox_playback::Player owns a cpal
Stream (!Send/!Sync), so moving Arc<Player> into the MPRIS thread can
never compile. Rework the integration on fin's fin-mpris pattern:
- mpris-server 0.10 with the tokio feature (same as fin), implementing
RootInterface + PlayerInterface on a struct that holds only Send-safe
channels: a watch::Receiver<NowPlaying> for reads and an mpsc sender
for transport commands.
- The TUI loop (and headless `atradio play`) publishes a NowPlaying
snapshot every tick and applies incoming MprisCmd on its own thread,
so the engine handle never crosses threads.
- A notify task diffs snapshots into PropertiesChanged signals.
- NowPlaying now carries volume; Player wrapper gains pause().
Cold CI runners re-download every flake input from the anonymous
api.github.com tarball endpoint, which keeps returning 503 (locally the
inputs are already in the Nix git cache, so no fetch happens — that's why
the failure never reproduces here). Switch to
DeterminateSystems/nix-installer-action@v16 + cachix/cachix-action@v17 —
the exact installer pair the green fin repo uses — which wires
github.token into Nix's fetchers so input downloads are authenticated.
Add `access-tokens = github.com=${GITHUB_TOKEN}` so Nix's flake-input
fetches use the authenticated (higher) rate limit, and retry `nix build`
once — the previous failure was a transient GitHub tarball-API 503, not a
flake problem.
A relative `path:./cli` flake input can't be locked inside a git repo, so
`nix build` failed with "lock file contains unlocked input". Extract the
crane build into a shared cli/package.nix imported by both flakes:
- cli/flake.nix builds it with src = ./.
- the root flake builds it with src = ./cli (default package)
Neither uses a path input now; both locks contain only GitHub inputs.
- Make cli/ a standalone crate (own Cargo.lock, hardcoded manifest,
release profile) so it can be its own flake; drop the single-member
root Cargo workspace.
- cli/flake.nix: crane+fenix build (packages.default = atradio), checks,
and a nix develop shell with the rust toolchain + ALSA/CoreAudio deps.
- Root flake.nix: delegate default/apps/devShells to the ./cli sub-flake
so nix run / nix profile install github:tsirysndr/atradio.fm resolve to
the CLI. Both flake.lock files generated.
- .github/workflows/release.yml: build macOS + Linux (x86_64/aarch64),
package tar.gz + .deb + .rpm, publish a GitHub Release, and push
packages to Gemfury (skipped if FURY_TOKEN/FURY_ACCOUNT unset).
- .github/workflows/nix.yml: nix build + flake check on ubuntu + macos
with Cachix caching (CACHIX_AUTH_TOKEN).
- cli/dist: deb control (amd64/arm64), rpm spec, homebrew formula
template. cli/LICENSE: GPL-2.0 (rockbox linkage).
- cli/README: install docs (Homebrew, apt/dnf via Gemfury, Nix, source),
preview image, aligned keybindings; drop "synthwave" wording.
Enhance OAuth handling, add stations tab, and internationalize app
- Critical: writes (play-status, favorite, comment, add-station) only
resumed an app-password session, so OAuth users' writes silently failed
— recently-played never updated. Add a unified write path that resumes
either CredentialSession or OAuthSession.
- Don't request/sync fm.atradio.audio.settings: the native Rockbox EQ
bands (32Hz-16kHz) differ from the web build (60Hz-20kHz), so DSP stays
local (settings.toml) only. Dropped from the OAuth scopes.
- Topbar: never truncate the handle — shorten only the display name and
size the column to fit.
- Add a global "Recent" home tab (who's listening, station · by @actor),
tabs now 1-5.
- Show the user's recently-played in the Profile view; it's selectable
(↑↓) and playable (Enter), routing through the normal play path.
Add react-i18next with browser-language autodetection and a persisted
choice (localStorage), a Navbar language switcher, and locale files
auto-discovered via Vite glob (no central registration). Translate all
routes, modals, and the player across en/fr/pt namespaces.
ProfilePage and PermissionBanner were committed separately in 746fc75.
Add profile and permission namespaces (en/fr/pt) and replace
hardcoded strings with t()/<Trans>, using interpolation for
actor names and plurals for favorite/station counts and tabs.
- Fix garbled TUI on OAuth: suspend the alt-screen/raw mode and run the
browser flow inline, then restore.
- Fix wrong handle: OAuth session_info().1 is the session id, not the
handle — resolve real handle + display name from the DID via the public
getProfile for both login paths.
- Request fm.atradio.* write scopes during OAuth (station, favorite,
comment, reaction, actor.status, audio.settings).
- Add a "Yours" home tab (4) listing the user's own stations, loaded on
login and after creating one.
- Add an add-station modal form (A): name / stream URL / genre / homepage
/ logo, Tab/↑↓ to move, Enter writes a fm.atradio.station record.
- Update help + footer for 1-4 tabs and A.
Press s (when signed out) to open a sign-in modal: enter a handle / DID /
PDS URL and Enter starts jacquard's loopback OAuth flow in the browser.
The modal shows progress, locks input while the browser flow runs, and
Esc cancels. On success the topbar + profile update and favorites /
notifications load. s still signs out when signed in.
Footer shows current year, atradio.fm copyright, "Baked with ❤️ in
Antananarivo", and Bluesky/Discord/Tangled icon links. Extract the
Tangled knot mark into a shared IconTangled component reused by the
navbar and footer.
- s: sign in (app password from env) / sign out inside the TUI, with
async login and auto-load of favorites + notifications.
- Fetch the connected user's display name from the public Bluesky API on
login; topbar now shows "Display Name (@handle)".
- Show live-decoded audio format in the player bar: codec · bitrate ·
sample rate (e.g. "MP3 · 128 kbps · 44.1 kHz").
- 1/2/3 jump to the Trending / Popular / Favorites tabs (tabs numbered).
- p: a Profile view (display name, @handle, DID, sign-in method, PDS,
favorites count, now listening).
- Update the help screen and footer hints for all new keys.
A native Rust client for atradio.fm in cli/ (workspace member `atradio`):
- clap CLI with a synthwave-styled help; default launches the TUI.
Subcommands: search, play, trending, login (app-password + --oauth),
logout, whoami.
- ratatui TUI inspired by the web app: trending/popular/favorites lists,
player bar, fzf-style fuzzy search (/), equalizer + full Rockbox DSP
panel (e), comments (c/a), notifications (n), help (?).
- Audio via rockbox-playback (>=0.4.1) with the full DSP chain (10-band
EQ, tone, crossfeed, PBE, surround, compressor, channel/width).
- atproto via jacquard: password + OAuth login (reads env), PDS writes
for favorites/comments/play-status.
- Public AppView reads + radio-browser search over reqwest.
- Typed fm.atradio.* bindings generated from packages/lexicons via
jacquard-codegen (src/fm_atradio, scripts/gen-lexicons.sh).
- TOML settings persistence (volume + DSP) under ~/.config/atradio.
- Linux-only MPRIS (D-Bus) integration for media keys / desktop panels.
Also: sync the Clojure console (bb.edn + console.clj) with cli:* tasks,
and ignore Rust target/ output.
Note: linking rockbox-playback (GPL-2.0) makes the binary GPL-2.0-or-later.
The bell's overlay lived inside the backdrop-blur header, whose
backdrop-filter made the header the containing block for `fixed` — so
`fixed inset-0` was clipped to the topbar instead of the viewport.
Mobile now navigates to a real /notifications route that mounts fresh
and fills the screen, with a full-height centered empty state. Desktop
keeps the anchored dropdown.
- Restyle @mentions to synth-purple and keep them linking to the mentioned
user's profile
- Detect http(s) URLs in comment text and render them as synth-blue links that
open in a new tab (rel=noreferrer noopener nofollow), trimming trailing
punctuation and coexisting with mention facets
- Recently-played: constrain the horizontal row (min-w-0/w-full) so it stops
forcing the page wider than the viewport on mobile (verified 0 overflow at
390px); the section is no longer pushed off-screen.
- Fullscreen mobile modals: add a mobile-only close (✕) button to comments,
login, add-station, audio-settings, shortcuts, and search-palette.
- Audio settings: raise its modal above the fullscreen player (z-60) so the
button works from the player instead of opening it hidden behind.
- Loading states: replace spinners with react-content-loader skeletons for the
comments list and the profile page.
- Mention popup: anchor the @-typeahead under the caret line (mirror-div caret
coordinates) instead of at the bottom of the textarea.
Content modals now fill the screen edge-to-edge on mobile (max-sm) while
keeping their desktop sizing untouched, via a shared MOBILE_FULLSCREEN_DIALOG
helper. Applied to comments, media picker, login, add-station, audio settings,
shortcuts, and the search palette; bodies flex-fill and scroll within the full
height.
Deleting a comment now opens a confirmation dialog that previews the exact
comment being removed, and the delete is hardened to act strictly on that one
comment's uri (bails on a missing uri) so it can never affect others.
- Position the reaction picker centered on the smiley trigger (clamped so a
wide row never runs off a screen edge) instead of screen-centered
- Vertically center the picker content: symmetric padding + scale-only dock
magnify from center, so the row stays balanced and never clips
- Add vertical spacing between the listeners/track meta and the play controls
in the fullscreen player
Login failed with invalid_scope ("repo:fm.atradio.comment is not declared
in the client metadata") because the OAuth client metadata's declared scope
didn't include the newly-added collections. Add them so the authorization
server accepts the request, and note the sync requirement next to OAUTH_SCOPE.
A session granted before we added the comment/reaction collections can't
write those records. Detect the gap and surface it instead of failing silently.
- missingScopes() diffs the session's granted OAuth scope against the app's
required scopes (derived from OAUTH_SCOPE)
- PermissionBanner shows a top-of-screen warning naming the missing features
and offering a "Log out & sign in" action; not dismissible — it clears only
once re-authentication grants the new scopes
Add a full social layer on top of the AppView + PDS + Jetstream stack.
Lexicons
- fm.atradio.comment (station snapshot, text, mention facets, optional GIF)
- fm.atradio.reaction (ephemeral emoji reaction)
- defs: commentView, notificationView, comment#gif, comment#mention
- queries getComments/getNotifications, procedure updateSeen
- TS types, zod schemas, buildComment/buildReaction mappers, LiveEvent types
API
- comments, reactions, notifications, notification_seen tables (+migration)
- Jetstream consumer indexes comments/reactions and fans out notifications
(mentions -> mentioned actors; comments -> custom-station owner)
- Redis-pub/sub-or-in-process live bus + SSE GET /live/:stationId
- XRPC getComments, getNotifications (unread count), POST updateSeen
Web
- putComment/putReaction to PDS; appview clients + SSE subscribe
- Klipy media client (GIFs / stickers / clips) for comment embeds
- mention richtext: facet resolution, segment rendering, live @-typeahead
- CommentComposer, CommentsPanel (live + optimistic), CommentsModal
- NotificationBell with floating unread badge that resets on open
- StationReactions: macOS-dock emoji picker (hover/click, login-gated)
- PlayerReactionRain: full-screen bottom->top emoji rain, real time
- wired into Player (mini + fullscreen), StationCard, Navbar, Layout
- OAuth scope adds repo:fm.atradio.comment + repo:fm.atradio.reaction
http favicons were blocked as mixed content on the https app — Media
Session artwork and station logos silently failed to load.
- api: add GET /api/image?url= that reverse-proxies an image (image/*
responses only) with a day-long cache
- web: add proxiedImageUrl() — routes http image URLs through the proxy on
an https page, leaves https/data URLs (and dev http pages) untouched
- use it for the Media Session artwork and StationLogo
Expose OS/lock-screen media controls and metadata mirroring the miniplayer:
- MediaMetadata with the current track (falling back to station name),
station as artist, and the station favicon as artwork; updates on
station/track change
- playbackState kept in sync with play/pause
- transport action handlers (play/pause/stop) routed through the existing
isPlaying/stop flow; seek/track actions disabled for live radio
- the Rockbox engine plays through a raw AudioContext, which browsers won't
surface Media Session for, so anchor it with a hidden silent looping
<audio> element while the engine owns playback (the native path already
exposes a media element)
- the full-width player container swallowed clicks in its transparent
margins, making the shortcuts help FAB unclickable — make the container
pointer-events-none and only the card interactive, and lift the FAB
above the bar while a station plays
- add a `P` keyboard shortcut to toggle the fullscreen player (Esc closes
it); listed in the shortcuts help. Fullscreen state now lives in an atom
- make the miniplayer more translucent (bg-synth-surface/40)
- miniplayer is now a floating, translucent rounded bar (side/bottom
margins, backdrop blur, soft shadow) instead of an edge-to-edge bar
- add a fullscreen "now playing" view: large artwork, title, live track
metadata, big transport controls, favorite, EQ and volume; opens by
tapping the miniplayer meta (mobile) or the chevron (sm+), closes via
the down-chevron, backdrop, or Esc
- responsive: fullscreen is mobile-first (max-w-md, safe-area padding,
scrolls on short/landscape screens); hide the extra chevron and the
navbar Bluesky/Tangled icons on the crowded mobile navbar
For live radio the Rockbox engine parses the ICY StreamTitle once at
connect and then re-emits that same title on every per-second progress
event. Because a single engine metadata event permanently gated off the
server-side ICY poll, "now playing" froze on the first song.
- dedupe engine titles: only publish when the engine's own title actually
changes, so its stale per-tick repeats can't clobber a fresher value
- let the ICY poll apply any non-empty title it reads (it polls the live
StreamTitle server-side); a transient empty result keeps the current
title instead of blanking it
- api: extract playlist helpers (isUnwrappablePlaylist / isPlaylistUrl /
parseFirstStreamUrl) from the proxy into a testable lib and add vitest +
unit tests, incl. the reported avbhost .pls case and the HLS exclusion
- web: add resolve.ts tests (proxiedStreamUrl proxies playlists/mixed
content, resolveStream doesn't fetch plain .pls, unwraps TuneIn, detects
HLS, falls back on error)
- web: fix search.test.ts mocks to handle the /json/servers mirror
discovery introduced by the failover change
Playing a station whose streamUrl is a .pls/.m3u playlist failed silently:
/api/stream piped the playlist *text* to the decoder, which can't play a
playlist file (e.g. avbhost.com/.../alt64.pls).
- api: the stream proxy now resolves .pls/.m3u to the real stream URL before
piping, so it returns audio regardless of what the client sends. HLS
(.m3u8) is excluded — its segment URIs must resolve against the manifest.
- web: stop parsing .pls/.m3u client-side (reading /api/stream as text would
now hang on the live audio) and route playlist URLs through the proxy via
proxiedStreamUrl. This also fixes https .pls, which bypassed the proxy
before. TuneIn's finite Tune.ashx body is still unwrapped client-side.
Keep the row horizontally scrollable but hide the scrollbar across
Firefox, WebKit, and legacy Edge.
Icon-only external links to the atradio.fm Bluesky profile and Tangled
repo, styled to match the nav (muted, brighten on hover). Bluesky uses
IconBrandBluesky; Tangled uses its official knot mark inline via
currentColor so it inherits the theme color.
ensureUser overwrote cached handle/displayName/avatar with null whenever
a bsky getProfile lookup failed. Since enrichedDids resets on restart, a
restart during a rate-limit window re-resolved every actor at once, and
the null results clobbered good handles — every recently-played actor
collapsed to "someone" linking to a raw DID.
- never overwrite a good handle with null: on a failed lookup just ensure
a bare row exists (onConflictDoNothing) and leave the DID un-enriched so
it retries later
- treat the users table as a read-through cache: skip the bsky call when a
handle refreshed within a 24h TTL is already cached, avoiding the
rate-limit bursts that caused the clobbering
- add getProfiles() batch lookup + a startup backfillMissingHandles() that
re-resolves and heals rows already blanked to null
Drop the teal `--accent` fill (and the two-color neon glow on the main
transport button) in favor of a soft, neutral translucent white/10 button
with a light icon. Applied to the main player and station-card play
buttons; other primary buttons keep the accent.
Individual radio-browser mirrors get decommissioned or go flaky without
notice (de2/nl1/at1 intermittently reset connections while de1 stays up).
Pinning to one random hardcoded mirror per session broke every session
that drew a dead host — the cause of "works locally, fails deployed".
Discover live servers at runtime from all.api.radio-browser.info/json/servers
and try them in shuffled order on every request, with the round-robin
directory as a guaranteed fallback. Aborts still propagate; only a fully
exhausted mirror list throws.
Clicking a category tile opened the search palette modal with a name
search capped at 12 results. Replace that with a real /browse/$category
route that infinite-scrolls the entire genre via radio-browser's
paginated `bytag` endpoint.
- add browseRadioBrowserByTag() with offset/limit pagination
- add useCategoryStations() infinite query (40/page, cross-page dedupe)
- add BrowsePage with IntersectionObserver infinite scroll
- move CATEGORIES into shared lib/categories with slug helpers
- category tiles now navigate instead of seeding the palette
synth-magenta resolves to green (#64e882); switch the listener-count badge
on StationCard and the miniplayer to synth-purple so it reads distinct from
the cyan/pink/green already in the card.
The "Recently played on atradio" row now shows one entry per listener —
their most recent play — instead of repeating a busy user many times.
getGlobalRecentlyPlayed groups by actor, taking max(played_at) and that
play's station; cursor paginates on the per-actor latest-play time.
Serving the app over https blocks http:// station streams as mixed content.
Add GET /api/stream?url= which reverse-proxies audio (bytes piped straight
through, ICY metadata + Range forwarded, upstream aborted on client
disconnect).
Only http:// stream URLs are rewritten, and only when the page itself is
https — https streams and dev keep playing directly. The Player routes
playback (rockbox engine, native <audio>, HLS fallback) through the proxy
while ICY "now playing" polling stays on the original url (runs server-side).
HLS manifests are never proxied (would break relative segment URIs).
Introduce fm.atradio.actor.status (singleton `self` record) written whenever
a station reaches "on air", plus shared defs actorInfo/playView/listenerCount.
API:
- new recently_played history table (one row per status update) + migration
- jetstream indexes actor.status plays; refactor commit handling into explicit
per-collection handlers (fixes the not-favorite => station fallthrough)
- XRPC: getRecentlyPlayed (per-actor, distinct), getGlobalRecentlyPlayed
(platform events + actor), getListenerCounts (count distinct did per station)
Web:
- PlayStatusSync writes the status record on play (keyed on station id)
- profile "Recently played" tab; home horizontal row of global plays
- unique-listener badge on StationCard and the miniplayer (useListenerCount)
- request repo:fm.atradio.actor.status OAuth scope
Rename the audio settings lexicon NSID and move its pkl/json to the
audio/settings path mirroring the NSID. Update all usages: NSID map,
zod schema $type, comments, tests, and the OAuth scope in
client-metadata.json (repo:fm.atradio.audio.settings).
The Linux build failed because rockbox_playback::Player owns a cpal
Stream (!Send/!Sync), so moving Arc<Player> into the MPRIS thread can
never compile. Rework the integration on fin's fin-mpris pattern:
- mpris-server 0.10 with the tokio feature (same as fin), implementing
RootInterface + PlayerInterface on a struct that holds only Send-safe
channels: a watch::Receiver<NowPlaying> for reads and an mpsc sender
for transport commands.
- The TUI loop (and headless `atradio play`) publishes a NowPlaying
snapshot every tick and applies incoming MprisCmd on its own thread,
so the engine handle never crosses threads.
- A notify task diffs snapshots into PropertiesChanged signals.
- NowPlaying now carries volume; Player wrapper gains pause().
Cold CI runners re-download every flake input from the anonymous
api.github.com tarball endpoint, which keeps returning 503 (locally the
inputs are already in the Nix git cache, so no fetch happens — that's why
the failure never reproduces here). Switch to
DeterminateSystems/nix-installer-action@v16 + cachix/cachix-action@v17 —
the exact installer pair the green fin repo uses — which wires
github.token into Nix's fetchers so input downloads are authenticated.
A relative `path:./cli` flake input can't be locked inside a git repo, so
`nix build` failed with "lock file contains unlocked input". Extract the
crane build into a shared cli/package.nix imported by both flakes:
- cli/flake.nix builds it with src = ./.
- the root flake builds it with src = ./cli (default package)
Neither uses a path input now; both locks contain only GitHub inputs.
- Make cli/ a standalone crate (own Cargo.lock, hardcoded manifest,
release profile) so it can be its own flake; drop the single-member
root Cargo workspace.
- cli/flake.nix: crane+fenix build (packages.default = atradio), checks,
and a nix develop shell with the rust toolchain + ALSA/CoreAudio deps.
- Root flake.nix: delegate default/apps/devShells to the ./cli sub-flake
so nix run / nix profile install github:tsirysndr/atradio.fm resolve to
the CLI. Both flake.lock files generated.
- .github/workflows/release.yml: build macOS + Linux (x86_64/aarch64),
package tar.gz + .deb + .rpm, publish a GitHub Release, and push
packages to Gemfury (skipped if FURY_TOKEN/FURY_ACCOUNT unset).
- .github/workflows/nix.yml: nix build + flake check on ubuntu + macos
with Cachix caching (CACHIX_AUTH_TOKEN).
- cli/dist: deb control (amd64/arm64), rpm spec, homebrew formula
template. cli/LICENSE: GPL-2.0 (rockbox linkage).
- cli/README: install docs (Homebrew, apt/dnf via Gemfury, Nix, source),
preview image, aligned keybindings; drop "synthwave" wording.
- Critical: writes (play-status, favorite, comment, add-station) only
resumed an app-password session, so OAuth users' writes silently failed
— recently-played never updated. Add a unified write path that resumes
either CredentialSession or OAuthSession.
- Don't request/sync fm.atradio.audio.settings: the native Rockbox EQ
bands (32Hz-16kHz) differ from the web build (60Hz-20kHz), so DSP stays
local (settings.toml) only. Dropped from the OAuth scopes.
- Topbar: never truncate the handle — shorten only the display name and
size the column to fit.
- Add a global "Recent" home tab (who's listening, station · by @actor),
tabs now 1-5.
- Show the user's recently-played in the Profile view; it's selectable
(↑↓) and playable (Enter), routing through the normal play path.
Add react-i18next with browser-language autodetection and a persisted
choice (localStorage), a Navbar language switcher, and locale files
auto-discovered via Vite glob (no central registration). Translate all
routes, modals, and the player across en/fr/pt namespaces.
ProfilePage and PermissionBanner were committed separately in 746fc75.
- Fix garbled TUI on OAuth: suspend the alt-screen/raw mode and run the
browser flow inline, then restore.
- Fix wrong handle: OAuth session_info().1 is the session id, not the
handle — resolve real handle + display name from the DID via the public
getProfile for both login paths.
- Request fm.atradio.* write scopes during OAuth (station, favorite,
comment, reaction, actor.status, audio.settings).
- Add a "Yours" home tab (4) listing the user's own stations, loaded on
login and after creating one.
- Add an add-station modal form (A): name / stream URL / genre / homepage
/ logo, Tab/↑↓ to move, Enter writes a fm.atradio.station record.
- Update help + footer for 1-4 tabs and A.
Press s (when signed out) to open a sign-in modal: enter a handle / DID /
PDS URL and Enter starts jacquard's loopback OAuth flow in the browser.
The modal shows progress, locks input while the browser flow runs, and
Esc cancels. On success the topbar + profile update and favorites /
notifications load. s still signs out when signed in.
- s: sign in (app password from env) / sign out inside the TUI, with
async login and auto-load of favorites + notifications.
- Fetch the connected user's display name from the public Bluesky API on
login; topbar now shows "Display Name (@handle)".
- Show live-decoded audio format in the player bar: codec · bitrate ·
sample rate (e.g. "MP3 · 128 kbps · 44.1 kHz").
- 1/2/3 jump to the Trending / Popular / Favorites tabs (tabs numbered).
- p: a Profile view (display name, @handle, DID, sign-in method, PDS,
favorites count, now listening).
- Update the help screen and footer hints for all new keys.
A native Rust client for atradio.fm in cli/ (workspace member `atradio`):
- clap CLI with a synthwave-styled help; default launches the TUI.
Subcommands: search, play, trending, login (app-password + --oauth),
logout, whoami.
- ratatui TUI inspired by the web app: trending/popular/favorites lists,
player bar, fzf-style fuzzy search (/), equalizer + full Rockbox DSP
panel (e), comments (c/a), notifications (n), help (?).
- Audio via rockbox-playback (>=0.4.1) with the full DSP chain (10-band
EQ, tone, crossfeed, PBE, surround, compressor, channel/width).
- atproto via jacquard: password + OAuth login (reads env), PDS writes
for favorites/comments/play-status.
- Public AppView reads + radio-browser search over reqwest.
- Typed fm.atradio.* bindings generated from packages/lexicons via
jacquard-codegen (src/fm_atradio, scripts/gen-lexicons.sh).
- TOML settings persistence (volume + DSP) under ~/.config/atradio.
- Linux-only MPRIS (D-Bus) integration for media keys / desktop panels.
Also: sync the Clojure console (bb.edn + console.clj) with cli:* tasks,
and ignore Rust target/ output.
Note: linking rockbox-playback (GPL-2.0) makes the binary GPL-2.0-or-later.
The bell's overlay lived inside the backdrop-blur header, whose
backdrop-filter made the header the containing block for `fixed` — so
`fixed inset-0` was clipped to the topbar instead of the viewport.
Mobile now navigates to a real /notifications route that mounts fresh
and fills the screen, with a full-height centered empty state. Desktop
keeps the anchored dropdown.
- Recently-played: constrain the horizontal row (min-w-0/w-full) so it stops
forcing the page wider than the viewport on mobile (verified 0 overflow at
390px); the section is no longer pushed off-screen.
- Fullscreen mobile modals: add a mobile-only close (✕) button to comments,
login, add-station, audio-settings, shortcuts, and search-palette.
- Audio settings: raise its modal above the fullscreen player (z-60) so the
button works from the player instead of opening it hidden behind.
- Loading states: replace spinners with react-content-loader skeletons for the
comments list and the profile page.
- Mention popup: anchor the @-typeahead under the caret line (mirror-div caret
coordinates) instead of at the bottom of the textarea.
Content modals now fill the screen edge-to-edge on mobile (max-sm) while
keeping their desktop sizing untouched, via a shared MOBILE_FULLSCREEN_DIALOG
helper. Applied to comments, media picker, login, add-station, audio settings,
shortcuts, and the search palette; bodies flex-fill and scroll within the full
height.
- Position the reaction picker centered on the smiley trigger (clamped so a
wide row never runs off a screen edge) instead of screen-centered
- Vertically center the picker content: symmetric padding + scale-only dock
magnify from center, so the row stays balanced and never clips
- Add vertical spacing between the listeners/track meta and the play controls
in the fullscreen player
A session granted before we added the comment/reaction collections can't
write those records. Detect the gap and surface it instead of failing silently.
- missingScopes() diffs the session's granted OAuth scope against the app's
required scopes (derived from OAUTH_SCOPE)
- PermissionBanner shows a top-of-screen warning naming the missing features
and offering a "Log out & sign in" action; not dismissible — it clears only
once re-authentication grants the new scopes
Add a full social layer on top of the AppView + PDS + Jetstream stack.
Lexicons
- fm.atradio.comment (station snapshot, text, mention facets, optional GIF)
- fm.atradio.reaction (ephemeral emoji reaction)
- defs: commentView, notificationView, comment#gif, comment#mention
- queries getComments/getNotifications, procedure updateSeen
- TS types, zod schemas, buildComment/buildReaction mappers, LiveEvent types
API
- comments, reactions, notifications, notification_seen tables (+migration)
- Jetstream consumer indexes comments/reactions and fans out notifications
(mentions -> mentioned actors; comments -> custom-station owner)
- Redis-pub/sub-or-in-process live bus + SSE GET /live/:stationId
- XRPC getComments, getNotifications (unread count), POST updateSeen
Web
- putComment/putReaction to PDS; appview clients + SSE subscribe
- Klipy media client (GIFs / stickers / clips) for comment embeds
- mention richtext: facet resolution, segment rendering, live @-typeahead
- CommentComposer, CommentsPanel (live + optimistic), CommentsModal
- NotificationBell with floating unread badge that resets on open
- StationReactions: macOS-dock emoji picker (hover/click, login-gated)
- PlayerReactionRain: full-screen bottom->top emoji rain, real time
- wired into Player (mini + fullscreen), StationCard, Navbar, Layout
- OAuth scope adds repo:fm.atradio.comment + repo:fm.atradio.reaction
http favicons were blocked as mixed content on the https app — Media
Session artwork and station logos silently failed to load.
- api: add GET /api/image?url= that reverse-proxies an image (image/*
responses only) with a day-long cache
- web: add proxiedImageUrl() — routes http image URLs through the proxy on
an https page, leaves https/data URLs (and dev http pages) untouched
- use it for the Media Session artwork and StationLogo
Expose OS/lock-screen media controls and metadata mirroring the miniplayer:
- MediaMetadata with the current track (falling back to station name),
station as artist, and the station favicon as artwork; updates on
station/track change
- playbackState kept in sync with play/pause
- transport action handlers (play/pause/stop) routed through the existing
isPlaying/stop flow; seek/track actions disabled for live radio
- the Rockbox engine plays through a raw AudioContext, which browsers won't
surface Media Session for, so anchor it with a hidden silent looping
<audio> element while the engine owns playback (the native path already
exposes a media element)
- the full-width player container swallowed clicks in its transparent
margins, making the shortcuts help FAB unclickable — make the container
pointer-events-none and only the card interactive, and lift the FAB
above the bar while a station plays
- add a `P` keyboard shortcut to toggle the fullscreen player (Esc closes
it); listed in the shortcuts help. Fullscreen state now lives in an atom
- make the miniplayer more translucent (bg-synth-surface/40)
- miniplayer is now a floating, translucent rounded bar (side/bottom
margins, backdrop blur, soft shadow) instead of an edge-to-edge bar
- add a fullscreen "now playing" view: large artwork, title, live track
metadata, big transport controls, favorite, EQ and volume; opens by
tapping the miniplayer meta (mobile) or the chevron (sm+), closes via
the down-chevron, backdrop, or Esc
- responsive: fullscreen is mobile-first (max-w-md, safe-area padding,
scrolls on short/landscape screens); hide the extra chevron and the
navbar Bluesky/Tangled icons on the crowded mobile navbar
For live radio the Rockbox engine parses the ICY StreamTitle once at
connect and then re-emits that same title on every per-second progress
event. Because a single engine metadata event permanently gated off the
server-side ICY poll, "now playing" froze on the first song.
- dedupe engine titles: only publish when the engine's own title actually
changes, so its stale per-tick repeats can't clobber a fresher value
- let the ICY poll apply any non-empty title it reads (it polls the live
StreamTitle server-side); a transient empty result keeps the current
title instead of blanking it
- api: extract playlist helpers (isUnwrappablePlaylist / isPlaylistUrl /
parseFirstStreamUrl) from the proxy into a testable lib and add vitest +
unit tests, incl. the reported avbhost .pls case and the HLS exclusion
- web: add resolve.ts tests (proxiedStreamUrl proxies playlists/mixed
content, resolveStream doesn't fetch plain .pls, unwraps TuneIn, detects
HLS, falls back on error)
- web: fix search.test.ts mocks to handle the /json/servers mirror
discovery introduced by the failover change
Playing a station whose streamUrl is a .pls/.m3u playlist failed silently:
/api/stream piped the playlist *text* to the decoder, which can't play a
playlist file (e.g. avbhost.com/.../alt64.pls).
- api: the stream proxy now resolves .pls/.m3u to the real stream URL before
piping, so it returns audio regardless of what the client sends. HLS
(.m3u8) is excluded — its segment URIs must resolve against the manifest.
- web: stop parsing .pls/.m3u client-side (reading /api/stream as text would
now hang on the live audio) and route playlist URLs through the proxy via
proxiedStreamUrl. This also fixes https .pls, which bypassed the proxy
before. TuneIn's finite Tune.ashx body is still unwrapped client-side.
ensureUser overwrote cached handle/displayName/avatar with null whenever
a bsky getProfile lookup failed. Since enrichedDids resets on restart, a
restart during a rate-limit window re-resolved every actor at once, and
the null results clobbered good handles — every recently-played actor
collapsed to "someone" linking to a raw DID.
- never overwrite a good handle with null: on a failed lookup just ensure
a bare row exists (onConflictDoNothing) and leave the DID un-enriched so
it retries later
- treat the users table as a read-through cache: skip the bsky call when a
handle refreshed within a 24h TTL is already cached, avoiding the
rate-limit bursts that caused the clobbering
- add getProfiles() batch lookup + a startup backfillMissingHandles() that
re-resolves and heals rows already blanked to null
Individual radio-browser mirrors get decommissioned or go flaky without
notice (de2/nl1/at1 intermittently reset connections while de1 stays up).
Pinning to one random hardcoded mirror per session broke every session
that drew a dead host — the cause of "works locally, fails deployed".
Discover live servers at runtime from all.api.radio-browser.info/json/servers
and try them in shuffled order on every request, with the round-robin
directory as a guaranteed fallback. Aborts still propagate; only a fully
exhausted mirror list throws.
Clicking a category tile opened the search palette modal with a name
search capped at 12 results. Replace that with a real /browse/$category
route that infinite-scrolls the entire genre via radio-browser's
paginated `bytag` endpoint.
- add browseRadioBrowserByTag() with offset/limit pagination
- add useCategoryStations() infinite query (40/page, cross-page dedupe)
- add BrowsePage with IntersectionObserver infinite scroll
- move CATEGORIES into shared lib/categories with slug helpers
- category tiles now navigate instead of seeding the palette
Serving the app over https blocks http:// station streams as mixed content.
Add GET /api/stream?url= which reverse-proxies audio (bytes piped straight
through, ICY metadata + Range forwarded, upstream aborted on client
disconnect).
Only http:// stream URLs are rewritten, and only when the page itself is
https — https streams and dev keep playing directly. The Player routes
playback (rockbox engine, native <audio>, HLS fallback) through the proxy
while ICY "now playing" polling stays on the original url (runs server-side).
HLS manifests are never proxied (would break relative segment URIs).
Introduce fm.atradio.actor.status (singleton `self` record) written whenever
a station reaches "on air", plus shared defs actorInfo/playView/listenerCount.
API:
- new recently_played history table (one row per status update) + migration
- jetstream indexes actor.status plays; refactor commit handling into explicit
per-collection handlers (fixes the not-favorite => station fallthrough)
- XRPC: getRecentlyPlayed (per-actor, distinct), getGlobalRecentlyPlayed
(platform events + actor), getListenerCounts (count distinct did per station)
Web:
- PlayStatusSync writes the status record on play (keyed on station id)
- profile "Recently played" tab; home horizontal row of global plays
- unique-listener badge on StationCard and the miniplayer (useListenerCount)
- request repo:fm.atradio.actor.status OAuth scope