🎧

Pocket Player β€” Architecture Review

Deepening opportunities: where a shallow shape (interface nearly as complex as its implementation) or a smeared concern could become a deep module β€” a lot of behaviour behind a small interface, placed at a clean seam, and testable through that interface. Framed in the /codebase-design vocabulary (module Β· interface Β· depth Β· seam Β· adapter Β· leverage Β· locality) and the domain names from CLAUDE.md (no CONTEXT.md exists yet).

2386
lines in MainWindow.cpp Β· 74 methods Β· 25 project includes Β· 115 connect()
1027
SettingsDialog.cpp Β· 11-arg ctor Β· QSettings keys in 14 files
4
sibling remote-source modules, no shared seam
0
tests β€” the interface is the test surface, and the trickiest code has none
Strong Worth exploring Speculative β€” recommendation strength on each card.
CANDIDATE 01 Β· highest friction

MainWindow is the app's wiring harness

Strong

Files

  • MainWindow.h:159–235 β€” ~60 members
  • MainWindow.cpp:290–390 β€” startLibraryThread (2 worker threads, ~20 connects)
  • MainWindow.cpp:339–389 β€” import pipeline wired as lambdas
  • MainWindow.cpp:2272–2305 β€” startSubsonicSync lifecycle

Problem

A UI class belongs here β€” but dissolved into it are whole subsystems that aren't UI: the two-phase import coordination (enumerate β†’ hydrate β†’ write m3u8 β†’ notify), the worker-thread ownership for the library + probe, and the Subsonic-sync lifecycle. The tell is 25 project includes: MainWindow depends on nearly the whole codebase because it is the wiring harness. It changes for a dozen unrelated reasons (Divergent Change).

Solution (sketch β€” no interfaces yet)

Shed the non-UI coordinators into their own deep modules β€” an import coordinator owning :339–389, a library host owning the thread wiring, a Subsonic sync host owning :2272–2305. MainWindow keeps layout + user-intent routing and talks to each through a narrow seam.

Benefits

  • Locality: "how does an import finish?" lives in one module, not smeared across constructor lambdas.
  • Leverage: each coordinator becomes reusable/mockable; the pop-out and CLI paths could drive the same seam.
  • Testability: the import + sync state machines gain an interface to drive β€” today they're only reachable by constructing the whole window.
Deletion test: deleting these coordinators wouldn't move complexity β€” it would re-appear across callers. They earn their keep; they're just not born yet.
BEFORE β€” one mass, many reasons to change
MainWindow Β· 2386 lines Β· 74 methods
widget layout tree model import coordination library/probe threads Subsonic sync Subsonic cover HTTP playlist glue settings marshalling visualizer host
amber / sky = deep modules trapped inside
AFTER β€” a small window + deep modules across seams
MainWindow Β· layout + intent routing
widget layout tree model
β”Š narrow seams β”Š
Import
Coordinator
Library
Host
Subsonic
Sync Host
CANDIDATE 02 Β· complexity spread, not concentrated

There is no Settings module

Strong

Files

  • Stringly-typed QSettings keys in 14 files
  • SettingsDialog.h:29–32 β€” 11-arg positional ctor
  • MainWindow.cpp:1402–1406 β€” re-reads keys the dialog already wrote
  • SettingsDialog.h:105–136 β€” some controls self-persist, others don't

Problem

Two contradictory persistence contracts coexist. Getter-based settings (folders(), restoreQueue()…) are read by the host on Accept; other controls write QSettings themselves. So after one dialog round-trip, MainWindow must re-read playback/preferHq and playback/ignoreTitles back out to push them into the controller (:1402–1406). Keys like ui/restoreQueue are duplicated 2–3Γ— as bare strings; a typo in one site fails silently. This is Primitive Obsession at codebase scale β€” a domain concept (a setting) smeared as loose strings instead of a type.

Solution (sketch)

One deep Settings module owns every key and its default; both the dialog and the app read/write through it. The QSettings backend hides behind that single seam (so an in-memory adapter becomes possible for tests). The 11-arg ctor collapses when the dialog reads settings itself rather than being handed them positionally.

Benefits

  • Locality: a key is defined once; typos become compile errors, not silent no-ops.
  • Leverage: one place gives every caller defaults, migration, and change-notification.
  • Testability: the seam admits a fake store β€” settings-dependent logic becomes unit-testable without a real QSettings file.
BEFORE β€” two paths, keys duplicated across 14 files
flowchart TB subgraph keys["QSettings (stringly-typed, 14 files)"] K1["ui/restoreQueue"]:::k K2["playback/preferHq"]:::k K3["ytdlp/path"]:::k end DLG["SettingsDialog
1027 lines"] -- "self-persists some" --> keys DLG -- "getters for others" --> MW["MainWindow"] MW -- "writes some on Accept" --> keys MW -- "re-reads what the
dialog already wrote :1402" --> keys classDef k fill:#3b1d1d,stroke:#b45454,color:#fca5a5,font-size:11px;
AFTER β€” one owner, one seam
flowchart TB DLG2["SettingsDialog"] --> S["Settings module
(keys + defaults + notify)"]:::deep MW2["MainWindow / controllers"] --> S S --> BK["QSettings adapter"]:::adp S -.->|test| FAKE["in-memory adapter"]:::adp classDef deep fill:#06371f,stroke:#34d399,color:#a7f3d0; classDef adp fill:#1e2735,stroke:#64748b,color:#cbd5e1,font-size:11px;
Two adapters = a real seam. A QSettings adapter and an in-memory test adapter genuinely vary across this seam β€” so it isn't speculative; it's earned.
CANDIDATE 03 Β· broken seam / domain leak

Subsonic cover fetching lives inside the window

Worth exploring

Files

  • MainWindow.cpp:870–917 β€” resolveSubsonicCover
  • MainWindow.cpp:858 & 872 β€” subsonic-cover: token parsed twice
  • SubsonicClient.h:72–76 β€” "covers NOT downloaded here"

Problem

The window parses the subsonic-cover:<server>:<id> token, SHA-1s it to a cache filename, checks the disk cache, resolves the server, builds the auth URL via SubsonicClient::coverArtUrl(), issues a raw QNetworkAccessManager::get, and writes the bytes β€” all Subsonic-client work implemented in the UI, guarded by an ad-hoc m_currentCoverToken race flag. The token format is a shared secret with no single owner. "How does a Subsonic cover appear?" forces a bounce across MainWindow ↔ SubsonicClient ↔ the DB's art_url token β€” Feature Envy across a leaky seam.

Solution (sketch)

Move cover resolution behind the Subsonic seam: the token, cache path, URL, and fetch become one module's business. The window asks for "the cover for this track" and receives a path β€” it stops knowing the token grammar or the auth scheme.

Benefits

  • Locality: one owner for the token grammar + cache + auth β€” no more parse-in-two-places.
  • Leverage: any surface (pop-out, notifications, MPRIS art) reuses the same cover resolver.
  • Testability: cover-path logic becomes testable off a fake network; today it's welded to a live QNetworkAccessManager in the UI.
BEFORE β€” UI reaches into another module's domain
flowchart TB MW["MainWindow::resolveSubsonicCover
:870–917"]:::leak MW --> P["parse subsonic-cover token"]:::leak MW --> H["SHA-1 β†’ cache filename"]:::leak MW --> C["check disk cache"]:::leak MW --> U["SubsonicClient::coverArtUrl()"]:::ext MW --> N["raw QNetworkAccessManager::get"]:::leak MW --> W["write bytes to art cache"]:::leak classDef leak fill:#3b1d1d,stroke:#b45454,color:#fca5a5,font-size:11px; classDef ext fill:#1e2735,stroke:#64748b,color:#cbd5e1,font-size:11px;
AFTER β€” the walk hides behind one method
flowchart TB MW2["MainWindow"] -->|"coverFor(track)"| R["Subsonic cover resolver
(token Β· cache Β· auth Β· fetch)"]:::deep R --> NET["network adapter"]:::adp classDef deep fill:#06371f,stroke:#34d399,color:#a7f3d0; classDef adp fill:#1e2735,stroke:#64748b,color:#cbd5e1,font-size:11px;

Pairs naturally with SubsonicClient's split-brain: it already mixes a static server config store (servers()/saveServers()) with an async sync client β€” the same module wanting to be two.

CANDIDATE 04 Β· bugs hide where there's no seam

PlayerController's prefetch / auto-next has no test surface

Worth exploring

Files

  • PlayerController.h:163–168 β€” 7 coupled members
  • PlayerController.cpp:221–265 β€” playInternal 5-way branch
  • PlayerController.cpp:284–296 β€” decideAutoNext (shuffle dedup)
  • PlayerController.cpp:298–311, 603–627 β€” two consumers kept in lockstep

Problem

The remote-play plan is spread across m_autoNext, m_prefetchUrl, m_prefetchStream, m_prefetchTriggered, m_history, m_historyPos, m_index β€” invariants enforced only by prose comments. playInternal branches 5 ways (remote / subsonic / prefetched / in-flight-prefetch / fresh), each poking prefetch state differently; decideAutoNext rolls the shuffle choice "once here so it isn't repeated by the prefetch and the actual advance." This is exactly where real bugs live (gapless prefetch, double-rolled shuffle) β€” and it's reachable only by driving QMediaPlayer::MediaStatus transitions through a worker thread. Meanwhile the pure helpers that got extracted (applyPreferHq, qualityTier) are the parts that need testing least. Testability was extracted where the bugs aren't.

Solution (sketch)

Pull the queue/history/prefetch decision into a deep module that takes the current state + an event ("track ended", "prefetch resolved") and returns what to do next β€” no QMediaPlayer, no threads. The controller keeps the I/O; the planner keeps the logic.

Benefits

  • Testability: "prefetched track loads without a gap" and "shuffle doesn't double-roll" become plain unit tests β€” no event loop.
  • Locality: the seven fields + their invariants live in one place instead of four methods and a comment thread.
  • Leverage: a pure planner is trivial to reason about and to extend (e.g. new repeat modes).
BEFORE β€” logic tangled with I/O; testable bits are the wrong bits
PlayerController
7 coupled fields Β· invariants in comments
m_autoNext Β· m_prefetchUrl Β· m_prefetchStream Β·
m_prefetchTriggered Β· m_history Β· m_historyPos Β· m_index
playInternal β€” 5-way branch, pokes prefetch state
applyPreferHq βœ” tested-shaped
qualityTier βœ” tested-shaped
↑ the easy, pure bits are extracted; the hard bits aren't
AFTER β€” a pure planner behind a seam
PlayerController β€” owns I/O only
drives MediaEngine Β· RemoteResolver
β”Š seam β”Š
Playback planner (pure)
(state, event) β†’ next action
no QMediaPlayer Β· no threads Β· unit-testable
CANDIDATE 05 Β· shared shape, genuinely different insides

A shared "remote fetch job" seam

Speculative

Files

  • YtDlp.h (52) Β· RemoteResolver.h/.cpp (34 / 43)
  • SubsonicClient.h (103) Β· Importer.h (119)

Problem

Four modules all mean "run something async, hand back results," each re-deriving the same shell: an in-flight guard, a supersede-previous rule, a status(QString) progress signal, and a (done, failed) terminal pair. Importer and SubsonicClient independently re-invent "job queue + in-flight counter + emit-once-drained." That's Duplicated Code at the shape level.

Note too that RemoteResolver is borderline shallow: its whole .cpp is 43 lines β€” set the URL, run yt-dlp -g, split stdout, emit the first line. Its header even calls ytDlpPath() "a thin wrapper." Its interface is nearly as big as its body; it earns only a little keep (DRM-message mapping + pageUrl guard).

Why Speculative (the honest call): one adapter = a hypothetical seam; two = a real one. A yt-dlp QProcess job and a paged Subsonic REST sync are genuinely different insides. Forcing them under one abstraction risks Speculative Generality β€” a base that fits none well. Worth exploring whether a small shared "async job" helper removes real duplication, but don't build the grand unified fetcher on spec.
BEFORE β€” four siblings that never meet
flowchart LR subgraph s["each re-derives: in-flight guard Β· supersede Β· status() Β· (done,failed)"] Y["YtDlp"]:::b R["RemoteResolver
(43 lines, thin)"]:::thin I["Importer
job queue + in-flight"]:::b S["SubsonicClient
album queue + in-flight"]:::b end classDef b fill:#2a2140,stroke:#8b7bc0,color:#ddd6fe,font-size:11px; classDef thin fill:#3b1d1d,stroke:#b45454,color:#fca5a5,font-size:11px;
MAYBE-AFTER β€” a tiny shared job helper (not a framework)
flowchart TB J["async job helper
(guard Β· supersede Β· status Β· terminal)"]:::deep I2["Importer"] --> J S2["SubsonicClient sync"] --> J R2["RemoteResolver"] --> J classDef deep fill:#233047,stroke:#64748b,color:#cbd5e1,font-size:11px;

Also noted (lower friction β€” recorded, not carded)

Two-path metadata resolution

Live (PlayerController::onMetaDataChanged) and headless (MetadataProbe) funnel into applyResolvedMetadata via a updateNowPlaying flag β€” 5 modules / 2 threads for one concept, with a DB-schema fact (WHERE path='') leaking up into a UI method.

Threading flags that leak lifecycle

MusicLibrary::m_scanning (re-entrancy hack for the event-loop-pumping scan) and MediaEngine::m_pending{Volume,DeviceId,Visualizer} (construct→move→init bridge) both concentrate a real threading concern — they earn keep, but are the seams most likely to grow subtle bugs and have no test surface.

βœ” Exemplary depth β€” leave alone

Spectrum.h hides a from-scratch FFT + Hann window + log banding behind push/bands/reset. ShaderArt.h seals QRhi + the private-QShader trick behind 4 setters. These are the reference for "deep."

Visualizer wiring via scattered #ifdef

The visualizer's own modules are deep; only its wiring (HAVE_VISUALIZER across MainWindow, SettingsDialog, PlayerController) is smeared. Largely dissolves once Candidate 01 sheds the visualizer host.

⭐

Top recommendation β€” start with Candidate 04

Candidate 01 (splitting MainWindow) is the biggest friction, but it's a large, sprawling refactor touching everything β€” high risk with zero tests to catch regressions. Invert the order: extract the playback planner (Candidate 04) first. It's the smallest seam with the highest payoff β€” it wraps the code where real bugs actually hide (gapless prefetch, double-rolled shuffle), and unlike everything else it becomes unit-testable without an event loop. That gives you the first tests in the codebase, right around the trickiest logic β€” which then de-risks the larger MainWindow teardown.

Sequence: 04 (planner + first tests) β†’ 02 (a Settings module, another genuinely-earned test seam, and it unblocks the SettingsDialog shrink) β†’ 03 (close the Subsonic cover leak) β†’ 01 (shed coordinators from MainWindow, now with tests underneath). Hold 05 until duplication actually hurts β€” it's speculative today.

Diagnosis only β€” no interfaces proposed yet. Pick one and we'll walk the design tree together.