OD-7458 — the multi-PR refactor of Noice Android's playback queue. Goals: add Spotify/YouTube-style previous playback, make queue logic unit-testable without Android/ExoPlayer deps, and delete the object QueueManager god-singleton. Shipped on dev/OD-7458/queue_overhaul, 21 commits, 73 files changed, between 2026-05-20 and 2026-06-10.
LOC delta (honest read)
| Slice | Added | Deleted | Net |
|---|---|---|---|
| Raw diff (everything) | 4657 | 1063 | +3594 |
| Tests | 1733 | 2 | +1731 |
Docs (*.md) | 895 | 0 | +895 |
| Production code (incl. blanks + comments) | 2029 | 1061 | +968 |
| Production code (blanks + comments stripped) | 1350 | 861 | +489 |
Net +489 executable LOC for the feature + refactor. Roughly half the headline diff is tests, a quarter is docs, and ~14% is real production growth. The 861 deleted code-lines were the worst-coupled bits — god-object methods, observer chain, inline initQueue rebuild, Thread.sleep / System.gc hacks.
Context
The legacy queue lived in object QueueManager — a Kotlin singleton holding ObservableArrayList<ExoNotificationData>, with queue mutation, persistence, ExoPlayer media-source rebuild, content-version detection, and Cast sync all interleaved. Self-rated 4/10:
- Untestable (
object+BaseApplication+EventBus+PrefUtils+Logcouplings). - Race conditions —
OnListChangedCallbackfired on the UI thread while PlayerManager iterated the same list. - Lost work — items popped from index 0 went to
/dev/null. No previous-track support:setUsePreviousAction(false),onSkipToPrevious()commented out atPlayerManager.kt:1425. - Wasteful rebuild — every drag-reorder re-built the entire
ConcatenatingMediaSource, destroying the prepared source ExoPlayer was actively playing. - Silent failure —
PrefUtils.getQueueList()returnednullon parse failure → queue silently lost.
123 call sites across 38 files touched QueueManager directly. 0 tests covered queue logic.
This connects to a broader thread: doing the overhaul on legacy code is what triggered the [[kotzilla-noice-di-synthesis|"rewrite Noice in Compose"]] musing in [[2026-W21]].
Target architecture
A pure-Kotlin QueueEngine owns all queue logic, exposes a StateFlow<QueueSnapshot>, and has zero Android deps. Three Hilt @Singleton classes wrap it; one bridge talks to ExoPlayer.
┌──────────────────────────────────────────────┐
│ QueueEngine — pure Kotlin (@Singleton) │
│ StateFlow<QueueSnapshot>, mutation methods │
└──────────────────────────────────────────────┘
▲
┌──────────────────────────┼──────────────────────────┐
│ │ │
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ QueueReader │ │ QueueController│ │ QueuePayloadStore│
│ (read-only) │ │ (mutations + │ │ (HashMap<key, │
│ │ │ side effects)│ │ payload>, │
│ │ │ │ │ @Synchronized) │
└──────────────┘ └────────────────┘ └──────────────────┘
│
▼
┌────────────────┐
│ QueueExoBridge │ observes snapshot,
│ │ reconciles 2-slot
│ │ window via key-based
│ │ move (preserves prepared
│ │ MediaSource, no glitch)
└────────────────┘
Components at a glance
| Class | What it owns | Sample API |
|---|---|---|
QueueEngine | All queue state mutations, history, snapshot emission. Pure Kotlin, no Android deps. | advance(positionMs), previous(positionMs), addToManualQueue, replaceAutomaticQueue, applyContentEdits, restore, clear |
QueueReader | Pure reads. Safe to call from any thread. Narrow surface — callers that only display the queue can't accidentally mutate it. | queue, getMediaData(id), getPlayingData(), getFirstMedia(), getPayload(item), isPreviousAvailable(), isHandledByQueue() |
QueueController | Mutations + side effects (PrefUtils persistence, PlayerEvent dispatch, EventBus.QUEUE_UPDATE, catalog API fetches via ChannelPodcastApiRepository). 429 LOC — fattest class in the layer. | initializeQueue, onAutoAdvance(positionMs), addToManualQueue, alterAutomaticQueue, swipe, handleQueueItemClick, previous(positionMs), updateQueueData, clearAndNotify, reFetchQueue |
QueuePayloadStore | Thread-safe HashMap<key, ExoNotificationData>. Both Reader and Controller depend on it; neither has hidden access to the other's surface. | register, put, get, containsKey, clear (@Synchronized writes) |
QueueExoBridge | Observes engine snapshot, reconciles ExoPlayer's 2-slot playlist window via key-based move (preserves prepared MediaSource). Owns HLS URL normalization upfront. | attach(host, scope) — internal reconcile() runs on snapshot emit |
QueueBridgeHost | Pure-Kotlin interface the bridge writes to. PlayerManager implements it for production; tests use a list-backed FakeHost. | playlistSize(), keyAt(i), needsReload(i, item), removeAt(i), moveSlot(from, to), insertAt(i, item) |
Plus two small helpers:
QueueAccessors.kt(19 LOC) — top-levelqueueReader/queueControllerfor non-Hilt callers; resolves viaBaseApplication.commonEntryPoint.QueueItemMapper.kt(66 LOC) +QueueModels.kt(57 LOC) — payload ↔QueueItemmapping and snapshot data classes.FinishedThreshold.kt(69 LOC, lives innoice.app.player, notqueue/) — pure helper for the "is this item effectively finished?" rule (30s remaining OR 90% played). Used byprevious()to decide resume-at-0 vs resume-at-last-position, and by analytics.
Why three classes, not two
The original plan said QueueReader + QueueController. Shipped with a third class, QueuePayloadStore, holding the shared HashMap<key, ExoNotificationData>.
- Put it on Controller → Reader would have to inject Controller → narrow read surface transitively pulls in wide mutation surface.
- Put it on Reader → Reader has to expose
register / clear / put→ not read-only anymore. - Extract it → Reader gets
get / containsKey. Controller gets full write API. Neither has hidden access to the other. Cost: ~40 LOC.
Previous-playback semantics
On previous():
- If current playback position > 3 seconds → seek to 0 (don't change item).
- Else if
historynon-empty → push current back toupcoming.first, pophistory.last→ current. - Else → seek to 0.
Rules:
- History cap: 50 items.
- Resumes at last position (captured at the moment of
advance()viaprevData.timeElapsed→ stored onQueueItem.playbackPositionMs), not at 0. Mirrors podcast-listener expectation: "go back" means "back to where I was." - Finished-threshold rule (30s remaining OR 90% played) — if the popped item was past it, resume at 0 instead of last position.
- Hybrid persistence model (YouTube/Spotify mix): history survives auto-advance and in-queue navigation, resets on explicit "play this content" (anywhere
playFirstItem=trueis set — content detail, catalog page, search). Final decision after a flip-flop: pure YouTube-style felt wrong in QA because previous walked into a totally different prior session. - Per-content-type policy: enabled for podcast / audiobook / music; disabled for radio / livestream.
- Content edits while in history:
applyContentEditsdetectsupdatedAtchange for a history item → drops it from history. - History persists across app restarts (schema v2 with v1→v2 migration).
Testing strategy (TDD, Option B)
Did not characterize current QueueManager behavior — tests against it would be integration tests pretending to be unit tests. Instead wrote tests against the target QueueEngine interface.
- Red phase landed first (commit
d97a41fa4): 45 failing tests +TODO()interface stubs. Contract documented in code. - Green phase (
76642833c):QueueEngineImplgreens the 39 non-previous()tests. - PR 3 (
9e879682a): history +previous()greens the remaining 6. - PR 4 (hardening): property tests, integration tests, finished-threshold extraction.
94 @Test methods by the end (property layer runs many randomized cases per method):
| Layer | @Test methods | Style |
|---|---|---|
QueueEngine | 45 | Behavior-by-example |
| Engine invariants | 3 | Randomized stateful property test + 2 scoped invariants, covers 11 named invariants (caught 3 latent bugs) |
QueueExoBridge | 10 | Reconciliation against a list-backed FakeHost |
FinishedThreshold | 14 | Pure math, boundary cases |
QueueController | 22 | Orchestration sequences, request routing, edge cases |
All run in pure JVM (~50 ms total). The bridge is testable because QueueBridgeHost is a pure-Kotlin interface (playlistSize / keyAt / needsReload / removeAt / moveSlot / insertAt); PlayerManager implements it for production; tests use a FakeHost.
Bridge: move-instead-of-rebuild
The bridge maintains a 2-slot window [current, next] on the ExoPlayer playlist. On snapshot change it does key-based identity reconciliation: if "new-current" is already at slot 1, MOVE slot 1 → 0 instead of rebuilding the source. This preserves the prepared MediaSource ExoPlayer was actively playing — kills the audible glitch and "next briefly disabled" bug from the old initQueue() rebuild path.
HLS URL normalization moved upfront into QueueExoBridge.needsReload, deleting the post-hoc normalizeHlsUrl() cleanup at PlayerManager.kt:1531.
What got deleted
object QueueManager(584 lines) �� gone.QueueChangeObserver(theOnListChangedCallbackchain) — gone.Thread.sleep(50)+System.gc()hacks for edited-content reset — gone (engine handles it synchronously viaapplyContentEdits()).PlayerManager.initQueue()(~120 LOC of inline rebuild) — reduced to a one-line bridge attach.- The "demolish syncMirror" commit (
e955d3438) made the queue a derived snapshot view instead of a maintained mirror — eliminated a whole class of consistency bugs.
What still looks legacy (intentionally)
EventBus.post(QUEUE_UPDATE)is still how UI fragments learn the queue changed. Long-term fix:StateFlow<List<ExoNotificationData>>onQueueReader+ DiffUtil. Deferred until Compose adoption — bigger return on investment then.notifyDataSetChanged()in queue adapters. Same path.ConcatenatingMediaSource(deprecated in Media3 1.3.0+). The bridge already talks to the pureQueueBridgeHostinterface, so swap toplayer.addMediaItemis a one-class change.
Top-level queueReader / queueController accessors in QueueAccessors.kt resolve via BaseApplication.commonEntryPoint — service-locator shortcut so the big-bang migration was mechanical. Promotion to per-class @Inject is tracked as future work (~1 day, no user-visible benefit).
How execution diverged from the plan
- Big-bang QueueManager deletion (commit
d459a6a33) instead of the originally-planned 6-phase rollout. Trade-off: phased gives per-PR bisectability; big-bang ships clean state immediately. Chose big-bang because the migration was mechanical (replaceQueueManager.foo()withqueueReader.foo()/queueController.foo()) — small bug surface, 41 call sites in one reviewable diff. - Three classes, not two — see "Why three classes, not two" above.
- History reset on explicit play flip-flop (2026-05-21) — went pure-YouTube, reverted to hybrid after QA showed the cross-session walk-back felt wrong.
Commit timeline (origin/develop..dev/OD-7458/queue_overhaul)
| Date | Commit | Story |
|---|---|---|
| 2026-05-20 | d97a41fa4 | Red-phase baseline: 45 failing tests + QueueEngine interface |
| 2026-05-20 | 76642833c | Green phase sans previous() — QueueEngineImpl implemented |
| 2026-05-20 | 8b264cca8 | QueueManager wired as facade over QueueEngine |
| 2026-05-20 | 66f79b82a | Facade QA fixes: playing panel, next button, drag-reorder |
| 2026-05-21 | 9e879682a | PR 3 — history + previous() end-to-end |
| 2026-05-22 | 568b6946b | Notification + MediaSession previous routed through engine |
| 2026-05-22 | 613bd4602 | PR 2 — QueueExoBridge extracted from PlayerManager |
| 2026-05-25 | 4af50a32b | Harden persistence + drop legacy queue observer |
| 2026-05-26 | e955d3438 | Demolish syncMirror; queue becomes a derived snapshot view |
| 2026-05-28 | 3745a9c2b | Hardening QA fixes: next autoplay, catalog leak, previous edge cases |
| 2026-05-29 | 37cf3a4c2 | Previous resumes at last position; restart if finished |
| 2026-05-30 | dfaec2e69 | Property tests for engine invariants + 3 bug fixes |
| 2026-06-01 | b67661dfa | Bridge integration tests with list-backed fake host |
| 2026-06-01 | 3398035ce | Extract FinishedThreshold helper + 12 unit tests |
| 2026-06-02 | 3f9df80f7 | Prune dead code in QueueManager / PlayerManager |
| 2026-06-02 | d459a6a33 | Delete object QueueManager; split into Reader + Controller + PayloadStore |
| 2026-06-02 | 0070f38bc | Document the deletion outcome |
| 2026-06-02 | 82ad63844 | Add before/after architecture comparison doc |
| 2026-06-10 | a767edeb7 | QueueControllerTest — 22 orchestration tests |
| 2026-06-10 | b35ccfe76 | Refresh docs after task #6 ships |
(Plus one earlier facade-wire commit. Full bodies in raw/work/queue-overhaul/commits.md.)
Quality rating (self-assessment)
| Dimension | Before | After |
|---|---|---|
| Testability of pure layers | 1/10 | 9/10 |
| Testability of orchestration | 1/10 | 8/10 |
| Separation of concerns | 2/10 | 6.5/10 (Controller bloated to 429 LOC during hardening) |
| Race safety | 3/10 | 8/10 |
| Feature support (previous, history, resume) | 1/10 | 9/10 |
| Observability | 4/10 | 7/10 |
| Hilt integration | 0/10 | 6/10 (top-level accessors still used) |
| UI reactivity | 3/10 | 3/10 (unchanged — gated on Compose) |
| Overall | ~4/10 | ~7.5/10 |
Named follow-ups holding back the score: UI reactivity (needs Compose adoption), per-class @Inject migration (mechanical), further Controller split into Persister + AutoFetcher (now 429 LOC).
Token-cost A/B — preliminary internal measurement
Ran the 5-task comprehension protocol (see raw/research/ai-code-quality/token-experiment/tasks.md) against both branches of noice-android:
- Branch A (control):
origin/develop— legacyobject QueueManager. - Branch B (treatment):
dev/OD-7458/queue_overhaul— refactored Engine / Reader / Controller / PayloadStore + bridge.
Identical prompts, Claude Code default model, one trial per branch.
| Metric | Legacy | Refactored | Delta |
|---|---|---|---|
| Total cost | $2.12 | $0.95 | −55% |
| Wall-clock time | 8m 34s | 3m 53s | −55% |
| API time | 11m 42s | 6m 29s | −45% |
| Haiku output tokens | 40.3k | 20.5k | −49% |
| Haiku cache reads | 9.1M | 3.8M | −58% |
| Opus output tokens | 9.6k | 2.1k | −78% |
What the data says
Same prompt, same agent, same model defaults, same agent topology (5 parallel Explore subagents per run, one per task). Only the code differs. The refactored branch cost 2.23× less to produce equivalent answers.
The Haiku cache-read drop (9.1M → 3.8M, 2.4× fewer reads) is the cleanest input-side signal: less context had to be reloaded across tool calls to reach equivalent understanding. The legacy object QueueManager intersects with nearly every queue-touching file, so any task that touches the queue layer pulls a wide swath of code into context. The refactored Engine/Reader/Controller/PayloadStore split lets each subagent focus on one named class.
Honest caveats
- n = 1 per branch. Single trial. Noise contribution unknown — a second pair would firm up the numbers.
- Model-mix differed. Opus did more output on the legacy run (9.6k vs 2.1k). Some of the cost delta is model selection — Claude Code escalates to Opus when Haiku struggles, which is itself a code-quality signal but not pure token volume.
- Output-shape differed. The legacy answer is more detailed walkthrough-style; the refactored answer is more concise. A quick blind quality cross-check would confirm both equally answer the same questions.
- Raw files:
raw/work/queue-overhaul/[before] queue overhaul agentic cost test.mdand[after] queue overhaul agentic cost test.md.
This is, to my knowledge, the only published clean A/B (same task, varied code quality on the same codebase, identical agent topology) for agentic LLM token cost. Even with n=1, it's the closest empirical answer Q3 has gotten — and it points in the direction the thesis predicted, hard.
Related
- [[kotzilla-noice-di-synthesis]] — overhaul reinforced the "rewrite in Compose" thread
- [[hilt-to-koin-migration]] — DI direction for the broader app
- [[2026-W21]], [[2026-W22]] — weekly notes during the overhaul
- Raw material:
raw/work/queue-overhaul/(4 docs + commits + diff summary)