14 KiB
name, overview, todos, isProject
| name | overview | todos | isProject | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Runbuddy Mode Plan | Add 1:1 RunBuddy sessions: leader creates a public session on the server, follower picks it from an in-app billboard, and both stay in sync via WebSocket events timestamped with NTP-corrected wall clock. Both devices use the same Spotify playlist and local MP3 pairings. |
|
false |
RunBuddy Mode — Implementation Plan
Scope (confirmed)
- 1 leader + 1 follower per session (session locks when follower joins)
- Same Spotify playlist + same local MP3 pairings on both devices
- Leader is source of truth for playback transport and position
- Position sync target: millisecond-level via
NtpClient.kt+ timestamped realtime events
Current baseline
| Layer | Today | Gap |
|---|---|---|
| Playback | PlaybackService owns queue, play/pause/seek/skip locally |
No remote command ingress |
| Networking | REST only (MetadataSyncClient, jukebox) |
No sessions, no WebSocket |
| Server | Flask monolith lockstep-2-api/api.py |
No session model, no realtime |
| Clock | NtpClient exists but unused |
Wire into session lifecycle |
sequenceDiagram
participant Leader as LeaderApp
participant Server as api.lockstep.at
participant Follower as FollowerApp
participant NTP as pool.ntp.org
Leader->>NTP: NtpClient (offset)
Leader->>Server: POST /runbuddy/sessions
Server-->>Leader: sessionId, joinCode, wsToken
Leader->>Server: WS connect (leader)
Follower->>Server: GET /runbuddy/sessions (billboard)
Follower->>Server: POST /runbuddy/sessions/{id}/join
Follower->>NTP: NtpClient (offset)
Follower->>Server: WS connect (follower)
Leader->>Server: transport + position events (correctedMs)
Server->>Follower: fan-out events
Follower->>Follower: apply to PlaybackService
1. Server — session model + billboard + WebSocket
Extend lockstep-2-api/api.py (or split into runbuddy.py imported by api.py to keep the monolith manageable).
SQLite tables
runbuddy_sessions
id(UUID, PK)host_spotify_user_idplaylist_id,playlist_namepaired_track_count(leader-reported, for follower pre-check)status(waiting|active|ended)created_at,expires_at(TTL, e.g. 2h idle)
runbuddy_participants
session_id,role(leader|follower)spotify_user_id,joined_at- Unique constraint: one follower per session
runbuddy_ws_tokens
- Short-lived join token per participant (avoid putting Spotify bearer in WS query string long-term)
REST endpoints (all @require_auth with existing Bearer Spotify token)
| Method | Route | Purpose |
|---|---|---|
POST |
/runbuddy/sessions |
Leader creates session. Body: { playlistId, playlistName, pairedTrackCount }. Returns { sessionId, joinCode, wsUrl, wsToken, snapshot }. |
GET |
/runbuddy/sessions |
Billboard: list status=waiting public sessions (host display name, playlist name, paired count, created_at). |
GET |
/runbuddy/sessions/{id} |
Session snapshot for late join / reconnect. |
POST |
/runbuddy/sessions/{id}/join |
Follower joins (409 if full). Returns { wsUrl, wsToken, snapshot }. |
POST |
/runbuddy/sessions/{id}/end |
Leader ends session. |
POST |
/runbuddy/sessions/{id}/leave |
Follower leaves. |
Snapshot (stored server-side, updated by leader over WS) includes current transport state:
{
"playlistId": "...",
"playlistPosition": 3,
"trackId": "spotify:track:...",
"positionMs": 45123,
"isPlaying": true,
"correctedMs": 1717171717171
}
WebSocket channel
Add a realtime layer to the Flask deployment. Practical options:
- Recommended for MVP:
flask-sock(simple, works with existing Flask app) or a small sidecar ASGI service if production WSGI cannot hold long-lived connections - Route:
GET /runbuddy/ws?sessionId=&token= - In-memory room registry keyed by
sessionId(acceptable for 1:1 MVP; document Redis upgrade path if scaling)
Server responsibilities:
- Authenticate token → session + role
- Relay JSON messages leader → follower (follower messages limited to
ping,leave, optionalready) - Persist latest snapshot on leader
state/positionmessages - Close room and set
status=endedon leader disconnect or explicit end
Operational notes
- Add session TTL cleanup cron or lazy expiry on billboard GET
- Rate-limit session creation (already flagged in
BUGS.md) - WebSocket requires production config change (proxy timeout, upgrade headers) — document in deploy notes
2. Sync protocol — NTP + event timestamps
Shared clock helper (new Android class)
RunBuddyClock (proposed):
// offsetMs = local ahead of NTP (from NtpClient)
fun correctedNowMs(): Long = System.currentTimeMillis() - offsetMs
- Call
NtpClient.clockOffsetSeconds()on session join (background thread) and optionally refresh every ~5 min during active session - Both peers use the same reference (NTP), not server wall clock — satisfies your requirement and avoids adding a server time endpoint for MVP
Event types (WebSocket JSON)
| Type | Sender | Fields | Purpose |
|---|---|---|---|
transport |
leader | action: play/pause/seek/skip_next/skip_prev/track_change, correctedMs, positionMs, playlistPosition, trackId |
Immediate state changes |
position |
leader | correctedMs, positionMs, isPlaying, playlistPosition, trackId |
Heartbeat every 250ms (match existing UPDATE_INTERVAL_MS) |
snapshot |
server | full snapshot | On follower connect / reconnect |
session_end |
server | reason | Tear down |
All timestamps in events use correctedMs (NTP-aligned epoch millis).
Follower position correction
On each position or transport event:
targetMs = event.positionMs + (event.isPlaying ? followerClock.correctedNowMs() - event.correctedMs : 0)
drift = targetMs - localEngine.getCurrentPositionMs()
if |drift| > 30ms → engine.seekTo(targetMs)
if track mismatch → publishCurrentTrack by playlistPosition/trackId first, then seek
- 30ms threshold is a starting point; tune with real devices
- When paused, do not extrapolate — hold
positionMsexactly - Ignore heartbeats while a transport event is being applied (short debounce)
Leader behavior
- Local UI actions (
requestTogglePause,requestSeek, skip) also emit correspondingtransportevents before/after applying locally - Position heartbeats read
getPlaybackPositionMs()+RunBuddyClock.correctedNowMs() - Leader local controls remain enabled; follower controls disabled or no-op (show "following leader" UI)
3. Android — new modules and PlaybackService integration
New packages / files
| File | Role |
|---|---|
data/RunBuddyApiClient.kt |
REST: create, list, join, end, leave |
runbuddy/RunBuddyWebSocketClient.kt |
OkHttp WebSocket (already on classpath) with reconnect + backoff |
runbuddy/RunBuddyClock.kt |
NTP offset cache |
runbuddy/RunBuddyCoordinator.kt |
Session lifecycle, role, event encode/decode, snapshot apply |
runbuddy/RunBuddySessionState.kt |
Idle, Leading(sessionId), Following(sessionId) |
Wire clients from LockstepApplication (shared OkHttpClient).
PlaybackService changes (PlaybackService.kt)
Add a RunBuddyCoordinator reference (injected via new binder methods or constructor set from NavHost when session starts):
New public API:
attachRunBuddy(coordinator: RunBuddyCoordinator)detachRunBuddy()applyRemoteTransport(...)— internal mirror of play/pause/seek/skip/track change without re-emitting leader eventsisRunBuddyFollower(): Boolean— UI uses this to disable controls
Hook points (existing methods):
private fun setPlaying(playing: Boolean) { ... }
private fun skipDelta(delta: Int) { ... }
fun requestSeek(fraction: Float) { ... }
- Leader path: after local mutation, notify coordinator → WS send
- Follower path: coordinator calls
applyRemoteTransportinstead of userrequest*methods
Track identity: sync on currentPlaylistPosition + currentTrackId (not currentQueueIndex) to stay stable across paired-only queue indexing — aligns with existing PlaybackUiState fields and avoids the unpaired-track index bug called out in BUGS.md.
Pre-join validation (follower)
Before POST .../join:
- User must be signed in (existing Spotify token)
- Local
pairingDao.countPaired(playlistId) >= session.pairedTrackCount(and ideally equal) - Optionally verify each Spotify track id in leader snapshot queue is paired locally — fail fast with clear error if not
ViewModel (LockstepViewModel.kt)
Add thin session orchestration (not playback logic):
createRunBuddySession(playlistId)listPublicSessions()joinRunBuddySession(sessionId)endRunBuddySession()- Expose
runBuddyState: StateFlow<RunBuddySessionState>for UI
Keep playback sync inside PlaybackService + coordinator (consistent with DESIGN.md foreground-service ownership).
4. UI flows
Navigation (Routes.kt)
Add routes:
runbuddy/billboard— public session list- Reuse
nowPlaying/{playlistId}for both roles once connected
Leader flow
- Library → select playlist (must be fully paired)
- "Start RunBuddy" action (new button on Library row or playlist detail)
- Create session → show join code + "Waiting for buddy…"
- On follower join → navigate to Now Playing, start playlist via existing
startPlaylistPlayback() - Coordinator attaches as leader; heartbeats begin
Follower flow
- New entry point: RunBuddy from Library top bar or Settings
RunBuddyBillboardScreen— poll or pull-to-refreshGET /runbuddy/sessions- Tap session → validate pairings → join → navigate to Now Playing + start same playlist
- Coordinator attaches as follower; apply snapshot immediately
Now Playing UI (NowPlayingScreen.kt)
- Banner: "Leading RunBuddy" / "Following [host name]"
- Follower: disable play/pause/skip/seek slider (or show read-only progress)
- Leader: End session button
- Show sync status indicator (clock synced / WS connected / drift ms for debug builds)
Settings (SettingsScreen.kt)
Optional toggle: "Enable RunBuddy mode" (feature flag) — can defer if always-on is fine for MVP.
5. Suggested implementation phases
Phase A — Server foundation
- DB migration + REST CRUD + billboard list
- Manual test with curl/Postman
Phase B — WebSocket relay
- Token auth, leader→follower fan-out, snapshot persistence
- Test with
websocator a minimal HTML client
Phase C — Android clients
RunBuddyApiClient,RunBuddyWebSocketClient,RunBuddyClock- Unit-test event parsing + follower drift math (pure Kotlin)
Phase D — Playback integration
RunBuddyCoordinator+PlaybackServicehooks- End-to-end two-emulator or two-device test
Phase E — UI + polish
- Billboard + leader start/waiting screens
- Reconnect (follower re-joins, applies snapshot, resumes heartbeats)
- Session expiry, error toasts, 409 when session full
6. Risks and mitigations
| Risk | Mitigation |
|---|---|
| ExoPlayer seek latency causes drift | Periodic 250ms heartbeats + 30ms threshold; pause extrapolation |
| NTP blocked on mobile network | Fail gracefully; show "clock sync failed" and block join |
| Flask WS in production | Verify reverse-proxy WS upgrade; fallback sidecar if needed |
| Unpaired-track index bug | Sync on playlistPosition/trackId; fix BUGS.md issue in parallel |
| Leader backgrounded | Foreground service already running; WS kept alive in service process |
| Pairing mismatch | Pre-join validation against pairedTrackCount + track ids |
7. Out of scope (MVP)
- Group runs (1:N)
- Cross-playlist or streaming without local MP3s
- Syncing run-data sensor JSON over WS (only playback)
- Server-side beat/metadata sharing per session (reuse existing per-user metadata APIs later)