You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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.
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.
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.
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:
frontend/src/features/player/cast-session.test.ts (new), pure reducer tests, no React:
Manual hardware pass (note in PR for the user): cast, gapless advance ×2, volume change, device switch, cast→local resume. Agent cannot do this — list it as a checklist in the PR description.
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.
Problem
frontend/src/components/player/PlayerBar.tsxis 933 lines and holds the entire Sonos cast-session state machine inside React effects and refs: track-advance detection (TrackURIchange +pendingNextRef), scrobble-once arming (scrobbledRef,sessionStartPosRef), resume offsets across sink switches (baseOffsetRef,pendingBaseOffsetRef,pendingLocalSeekRef), device-switch teardown (prevDeviceIdRef), volume separation (local vscastVolume), 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 --> LocalCastSessionis 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:
next()onPLAYING → STOPPEDis only the end-of-queue fallback; advance detection isTrackURIchange (Sonos: Use on device queue to prevent large playback gaps #202).peekNext()result is cached in state and reused for both the set-next call and the post-advancejumpTo— never re-rolled under shuffle (Sonos: Use on device queue to prevent large playback gaps #202).volumeandcastVolumenever cross (Sonos casting: safer + better-synced volume handling #181).scrobbledRefsemantics — preserve exactly; one recording path per pitfalls Track play counts for local + peer media, expose via Subsonic API #197).Implementation plan
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: eachuseRefbecomes a named field ofCastStatewith a comment mapping it back. Before creating the file: checkfrontend/eslint.config.jsbounded-dir rules —features/player/may not import fromfeatures/*-admin/; the machine imports nothing but types, so it satisfies any placement, but confirmcomponents/player/PlayerBar.tsxis allowed to import fromfeatures/player/(it should be; onlyfeatures/*dirs are mutually restricted). If lint says otherwise, place it infrontend/src/lib/cast-session.tsand note why in the PR.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,getSonosStatefromfrontend/src/lib/api.ts) and the player store (frontend/src/stores/player.ts). Scrobbling goes through the existingscrobble()infrontend/src/lib/subsonic.ts.frontend/src/components/player/PlayerBar.tsx— delete the Sonos effect blocks and refs; keep local<audio>handling, rendering, and theisSonosbranch points, consuminguseCastSession. Target: PlayerBar under ~450 lines with zero Sonos-protocol knowledge.hub/src/routes/sonos.tsuntouched.Testing plan
Table-driven tests are the point of this refactor — each historical bug becomes a fixture:
frontend/src/features/player/cast-session.test.ts(new), pure reducer tests, no React:PLAYING(uriA) → PLAYING(uriB)→ exactly onejumpToeffect with the cached next index; nonext()re-roll under shuffle.PLAYING → STOPPEDwith empty queue →Ended, no advance.sonosSetVolumeeffect is emitted unless the user changed volume (this test should reproduce the reported jump — if it does, fix inside the machine and close Sonos bug: Unexpected volume jumps during track switching #206 in this PR; if not, document findings on Sonos bug: Unexpected volume jumps during track switching #206).castTo(B)while casting to A →stopDevice(A)effect precedes play on B (Sonos cast: stop playback on previous device when destination changes #198 regression guard).positionMsinto the local-seek effect (pendingLocalSeekRefsemantics, Sonos cast: resume from current playback position #194/Sonos: seek restarts track from 0 on lossless pass-through #204).frontend/src/components/player/PlayerBar.test.tsx(617 lines, exists): must keep passing with minimal mock changes; followdocs/frontend-testing.mdpatterns.pnpm lint:boundaryzero output; extendfrontend/src/features/boundary-lint.test.tsonly if a new carve-out was needed.pnpm verifyfor the repo; no federation suite needed (no/rest/*change) — but if any scrobble call path changes, re-read pitfalls Track play counts for local + peer media, expose via Subsonic API #197 first.Acceptance criteria
cast-session.tsimports nothing from React,lib/api.ts, or the store — pure types + reducer.useRefwhose comment mentions Sonos protocol behavior.Agent execution notes
Read
docs/sonos.md,docs/pitfalls.md("Frontend", "Sonos cast"), anddocs/frontend-testing.mdfirst. 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.