Date: 2026-06-13 (Session 9) Theme: Close the last gap in the Spotify feature: the *account link* and the *now-playing surface* in the mobile app. The in-car voice tool and OAuth backend shipped in Sessions 5–6; this session makes Spotify usable by hand - a Connect button on Profile and a live Now Playing widget on Home.


Background - what already existed

  • services/spotify.py - OAuth 2.0 PKCE (SpotifyAuth), Web API wrapper

(SpotifyClient: play/pause/skip/current), and the LLM SpotifyTool.

  • api/routes/spotify.py - /spotify/auth, /spotify/callback, /spotify/status.

So EXCAR could already control music by voice. What it could not do: let the driver *link their account from the app*, or *see what's playing* on a screen. The backend exposed a spoken sentence (get_current_track()), not data a UI can render, and there was no app entry point to start the OAuth flow.


1. A machine-readable Now Playing endpoint

What. SpotifyClient.now_playing() returns a structured dict - {is_playing, connected, track, artist, album, album_art_url, progress_ms, duration_ms} - and a new GET /spotify/now-playing route serves it.

Why. The existing get_current_track() returns a *sentence* ("Now playing X by Y") tuned for text-to-speech. A widget needs fields and an album-art URL, not prose. Reusing the spoken string would have meant parsing English back into data - fragile and wrong.

How. now_playing() calls /me/player/currently-playing, pulls the first album image as album_art_url, and always returns a dict: connected: false when no token, is_playing: false when nothing is active or Spotify is offline. The widget never has to handle an exception or an empty body.

Purpose. One endpoint the Home widget polls; all the "is it even connected?" logic lives server-side.


2. App-driven playback control

What. POST /spotify/control {action} (play / pause / skip), mirroring the voice tool, returns 409 when Spotify isn't linked.

Why. The Now Playing widget needs play/pause/skip buttons. Rather than a new code path, the route reuses the exact SpotifyTool the LLM calls, so voice and touch share one implementation.

How. get_spotify_tool() returns None when unauthenticated → the route raises 409 so the app can prompt "connect first" instead of failing silently.

Purpose. Touch and voice control stay in lockstep - one behaviour, two front ends.


3. Connect Spotify - Profile screen

What. A new CONNECTIONS section on Profile with a Spotify row: "Connect →" when unlinked, a green dot + "Connected" when linked.

Why. OAuth has to *start* somewhere the user can reach. Profile is where every other account-level setting already lives.

How. On focus, the screen calls /spotify/status to show the right state. "Connect" fetches /spotify/auth and opens the returned URL with React Native Linking.openURL - Spotify's login runs in the system browser, redirects to the device's /spotify/callback, and the next focus re-reads status as Connected. spotify is already in the app's LSApplicationQueriesSchemes.

Purpose. A driver links their account in two taps, from the screen they'd expect.


4. Now Playing - Home screen widget

What. A widget under the fuel card: album art, track, artist, and a play/pause + skip control. It polls every 5 s while the device is connected and hides itself entirely until Spotify is linked.

Why. "What's playing?" is the most common glance a driver makes. Showing it on Home - next to range - matches how the car is actually used.

How. NowPlayingWidget fetches /spotify/now-playing on mount and on a 5 s interval (cleared on unmount). It renders nothing unless connected && np.connected, so a driver who hasn't linked Spotify sees no dead UI. Buttons call /spotify/control then immediately refetch, so the play/pause icon reflects reality.

Purpose. A live, glanceable music surface that costs nothing visually when unused.


Bug / cleanup - lint debt in the touched file

What. Editing services/spotify.py surfaced pre-existing ruff findings unrelated to this feature: an unused import os, a nested-if (SIM102), and three raise statements inside an except without from (B904).

Why it matters. Our standard is "the file you touch leaves clean." Swallowing the original exception (B904) also loses the HTTP error context when a Spotify call fails - worth fixing while here.

Fix. Removed the dead import, combined the token-expiry check, and chained the three _SpotifyError raises with from e. No behaviour change; the file is now ruff-clean. (Pre-existing mypy Any-returns from json.loads were left as-is, consistent with the repo's tolerated baseline.)


Verification

  • Backend ruff clean on services/spotify.py + routes/spotify.py.
  • Mobile tsc --noEmit clean.
  • Full suite 54 passing (unchanged - this session is I/O + UI, no new pure logic

to unit-test; the endpoints are thin wrappers over the already-tested client).

  • Metro bundle reloaded with no errors.

Decision Log - what we rejected and why

New structured now_playing() instead of parsing get_current_track(). The spoken sentence is for TTS; turning English back into fields is fragile. Two methods, one purpose each. *Rejected:* a single dual-purpose method.

Reuse SpotifyTool for /spotify/control instead of new play/pause/skip routes. One implementation behind both voice and touch means they can never drift. *Rejected:* parallel REST handlers duplicating the client calls.

Widget hides until linked, rather than showing a "Connect" placeholder on Home. The Connect entry point belongs on Profile (with other account settings); a second prompt on Home would be redundant noise. *Rejected:* a Home-screen connect card.

OAuth via the system browser (Linking.openURL), not an in-app WebView. Spotify recommends the system browser for OAuth, and it avoids bundling a WebView + cookie handling. The existing /spotify/callback already closes the loop. *Rejected:* an embedded auth WebView.


Open items

  • Live end-to-end link test - needs the running device + real Spotify login

(SPOTIFY_CLIENT_ID is set; librespot/playback device on the Pi still pending, per the device backlog).

  • Progress bar - progress_ms/duration_ms are returned but not yet drawn; a

thin seek bar is an easy follow-up.


Credits. Idea & product: Mete Selçuk Şimşek. Engineering: Selim Fedakar. Tech lead: Atilla.