Skip to content

refactor(player): extract the Sonos cast-session state machine from PlayerBar.tsx #245

Description

@benders

FROM @claude: Proposal from a codebase + issue-backlog analysis (2026-07-08). Plan written for execution by a coding agent.

Problem

frontend/src/components/player/PlayerBar.tsx is 933 lines and holds the entire Sonos cast-session state machine inside React effects and refs: track-advance detection (TrackURI change + pendingNextRef), scrobble-once arming (scrobbledRef, sessionStartPosRef), resume offsets across sink switches (baseOffsetRef, pendingBaseOffsetRef, pendingLocalSeekRef), device-switch teardown (prevDeviceIdRef), volume separation (local vs castVolume), and the polling loop.

This is the project's defect factory: ~15 closed Sonos bugs (#181, #182, #194, #198, #202, #204, #208…) and 4 open ones (#191, #192, #206, #207) trace to state transitions that can only be reproduced with real hardware, because the logic is welded to React lifecycle and SOAP polling. The pitfalls "Frontend" table is mostly a list of this file's regressions.

Extracting the session logic into a plain, dependency-free TypeScript state machine makes this bug class table-driven-testable, and is a prerequisite for #191 (Now Playing) and #192 (grouping), which both add more device state.

Related issues

Design

stateDiagram-v2
    [*] --> Local
    Local --> Starting: castTo(deviceId)
    Starting --> Casting: device reports PLAYING
    Casting --> Casting: poll tick / volume / seek / setNext
    Casting --> Advancing: TrackURI changed (gapless auto-advance)
    Advancing --> Casting: jumpTo(cached pendingNext index)
    Casting --> Ended: STOPPED after PLAYING and no pendingNext
    Casting --> Local: switchToLocal (carry position offset)
    Casting --> Starting: switch device (teardown previous first)
    Ended --> Local
Loading

CastSession is a pure reducer: (state, event) → { state, effects[] }. Events come from two places only — user intents (play, pause, seek, setVolume, castTo, next, prev) and poll snapshots ({ transportState, trackUri, positionMs, volume }). Effects are descriptions ({ type: "sonosPlay", … }, { type: "scrobble", trackId }, { type: "stopDevice", deviceId }), executed by a thin React hook. No timers, no fetches, no React inside the machine — fully deterministic and unit-testable.

Encode the existing pitfall rules as invariants in the machine:

Implementation plan

  1. frontend/src/features/player/cast-session.ts (new) — the reducer + types (CastState, CastEvent, CastEffect, createInitialState). Port the logic currently in PlayerBar's Sonos effects (~lines 60–500) ref-by-ref: each useRef becomes a named field of CastState with a comment mapping it back. Before creating the file: check frontend/eslint.config.js bounded-dir rules — features/player/ may not import from features/*-admin/; the machine imports nothing but types, so it satisfies any placement, but confirm components/player/PlayerBar.tsx is allowed to import from features/player/ (it should be; only features/* dirs are mutually restricted). If lint says otherwise, place it in frontend/src/lib/cast-session.ts and note why in the PR.
  2. frontend/src/features/player/useCastSession.ts (new) — React hook owning: the poll interval, dispatching poll snapshots into the reducer, and executing effects via the existing API functions (sonosPlay, sonosSeek, sonosSetNext, sonosCommand, sonosSetVolume, getSonosState from frontend/src/lib/api.ts) and the player store (frontend/src/stores/player.ts). Scrobbling goes through the existing scrobble() in frontend/src/lib/subsonic.ts.
  3. frontend/src/components/player/PlayerBar.tsx — delete the Sonos effect blocks and refs; keep local <audio> handling, rendering, and the isSonos branch points, consuming useCastSession. Target: PlayerBar under ~450 lines with zero Sonos-protocol knowledge.
  4. No backend changes. hub/src/routes/sonos.ts untouched.

Testing plan

Table-driven tests are the point of this refactor — each historical bug becomes a fixture:

Acceptance criteria

  • cast-session.ts imports nothing from React, lib/api.ts, or the store — pure types + reducer.
  • Every pitfalls "Frontend" row about Sonos has a corresponding named test case.
  • PlayerBar.tsx contains no useRef whose comment mentions Sonos protocol behavior.

Agent execution notes

Read docs/sonos.md, docs/pitfalls.md ("Frontend", "Sonos cast"), and docs/frontend-testing.md first. This is Player-side frontend work: obey the Hub/Player boundary section of AGENTS.md. Feature branch + Draft PR; commit the machine+tests before rewiring PlayerBar.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions