Structured now-playing + control endpoints, Connect Spotify, the Home now-playing widget.
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.
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.
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.
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.
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.
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.
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.)
ruff clean on services/spotify.py + routes/spotify.py.tsc --noEmit clean.to unit-test; the endpoints are thin wrappers over the already-tested client).
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.
(SPOTIFY_CLIENT_ID is set; librespot/playback device on the Pi still pending, per the device backlog).
progress_ms/duration_ms are returned but not yet drawn; athin seek bar is an easy follow-up.
Credits. Idea & product: Mete Selçuk Şimşek. Engineering: Selim Fedakar. Tech lead: Atilla.