Feed and profile cards now show one slim line (small artwork, title,
artist, Open button) with the progress bar and reactions. The arrow
opens the rest: album, your other services, Share, and lyrics.
Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a
self-hosted copy, or be empty to turn lyrics off). The server looks each
song up once and caches it, including misses (retried after a week).
Timed lyrics follow along with the song; otherwise the first lines show
with "All lyrics".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Songs offered links to all five services. Settings now has "Your music
services": pick the ones you use and songs only show those, with the
main one as the big button. Until someone picks, they still see all of
them. Onboarding sets it to the app you said you listen with.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Media servers only learn the position when the player checks in (~10s),
so a poll can return an old number. A position up to 12s behind our
running estimate is now treated as stale; positions ahead, or further
behind (a rewind), are taken as-is. Phone-reported positions are exact
and always taken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
React to a friend's current song or any of their recent plays: tap an
emoji to add yours (or take it back), "☺+" for the quick six, and a full
picker with search and categories (Unicode emoji up to 13.1, generated
into the shared package so web and mobile match). One reaction per person
per play; a new one replaces the old. Chips show counts and, on hover/
long label, who reacted; yours is highlighted. Listeners and followers get
the update live. Not on your own plays, not across a block (blocking also
clears reactions between the two people).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Sign-up emails a confirmation link (24h); unconfirmed accounts see a
banner on the web with "send it again", and status in Settings.
- Change email (needs the password): the new address gets a confirmation
link and only replaces the old one once clicked; the old address gets a
heads-up with a reset-password link. Pending changes can be cancelled.
- Change password (needs the current one): signs out other sessions.
- Password fields have Show/Hide on web and mobile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
The server now sends each fresh position on every poll that has one, and
sends positions advanced to send time; clients count only the time since
they received it. Bars realign every poll and no longer drift by however
far the viewer's clock is off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Feed and profile cards show a progress bar with elapsed/total time, only
when the source reports a real position (Plex, Jellyfin, the Android
reader). It ticks locally between updates. Apple Music's guessed
position is no longer reported.
- The Android reader advances MediaSession's stale position to now, stamps
when it was read (the server corrects for upload delay), and reports
seeks. The server pushes an update to watchers when a position jumps.
- When a polled source says where in the song someone is, the next poll is
scheduled just after the song ends (never sooner than 5s), instead of
waiting out the full interval.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
"Forgot your password?" on web and mobile sends a one-time link (1 hour)
to the account's email; the website's reset page sets the new password,
signs out every other session and signs in. The request responds the same
whether or not the email has an account, and the email goes out in the
background so timing doesn't reveal it either. Only a hash of the link
token is stored. Sends through SMTP_* settings; without them, emails are
logged (and captured in tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Upload a profile picture on web and mobile. The API square-crops it to
512px WebP, strips metadata (phone photos carry GPS), stores it in
Postgres and serves it with immutable caching. Avatar URLs are stored as
paths, so they survive moving the API. Arbitrary avatar URLs are no
longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Followers/following lists (paginated), 'follows you', remove a follower
- Blocking severs follows both ways and hides each user from the other
(profile 404s, search, follows); the blocker sees activity hidden
- Reports for moderation (App Store guideline 1.2)
- First account on a server becomes admin; admins see the report queue,
resolve reports, and suspend accounts (signs out, blocks login, hides
the profile, stops sharing). set-role script to promote/demote
- users.onboardedAt drives first-run onboarding; existing accounts are
backfilled as already onboarded. Empty PATCH /me no longer 500s
- Remove Spotify account sign-in and polling: Spotify caps new apps at 5
accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the
Android reader. App-level catalog search stays for exact links
- Shared: QR code generation and profile-link parsing (verified with jsQR)
- Integration test timeout raised; password hashing is slow by design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Every feature is free for everyone (the plan system stays for later);
remaining limits are abuse guards
- Optional website donation link (NEXT_PUBLIC_DONATION_URL). Not shown in
the mobile apps, where tips to the developer generally need in-app
purchase
- Last.fm/ListenBrainz-sourced plays link to the user's profile, as
Last.fm's API terms require
- Connections track when they last saw activity; a scrobbler silent for
over a week prompts re-linking Spotify (its link expires every 6 months)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Apple Music: sign MusicKit developer tokens (ES256 via node:crypto),
accept the Music User Token from the iOS app, and poll recently played.
Apple has no now-playing API or play timestamps, so a new top song is
shown as playing for its length; connecting records a baseline so an
old song isn't announced as current
- Last.fm web auth ("Sign in with Last.fm") instead of typing a
username; session key stored encrypted
- Spotify accounts rejected by the 5-user development cap are sent back
with error=spotify_limited instead of a 500, so apps can offer
Spotify via Last.fm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
Phones that read system media sessions report playback themselves:
- POST /me/devices issues a per-phone token that can only call
POST /devices/now-playing; revocable, hashed at rest, 10 per user
- Reports name the app ("via Spotify"); unknown apps use the OS label
- Push-reported playback expires after 9 minutes of silence, since
phones send a heartbeat every 4 minutes while playing
Users can pause sharing (PUT /me/sharing: 1h, 8h, indefinitely). While
paused, connections aren't polled, phone reports are dropped, nothing is
added to history, and the current track switches to "played … ago".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
A play is recorded when a new track starts (pausing and resuming doesn't
count; returning after 10+ minutes does). Profiles show the last 10,
excluding the track playing now; 50 are kept per user. Recent plays are
free on every plan; the paid flag is renamed to fullHistory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
Servers advertise many addresses (public, relay, LAN, Docker bridges).
Use the last one that worked and race the rest only if it fails. Friends'
servers that keep failing back off exponentially instead of slowing
every poll. Errors now name each server and the underlying cause, e.g.
"blackcoffee: ECONNREFUSED", instead of undici's generic "fetch failed".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
- Shared: link building, feed ordering, input schemas, API client
- API unit: track matching, private-address blocking, token crypto,
plan expiry, provider response parsing
- API integration against Postgres and Redis: auth, follows and pins,
now-playing state, webhooks, OAuth return URLs, realtime, rate limits
- Tests use a dedicated *_test database and Redis DB, and refuse to wipe
anything else; fetch is stubbed so they never hit the network
- Pin Node to 22.13+ (.nvmrc), which Vite's native bindings require
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8