MunchFile
Playback queue overhaul (OD-7458)

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)

SliceAddedDeletedNet
Raw diff (everything)46571063+3594
Tests17332+1731
Docs (*.md)8950+895
Production code (incl. blanks + comments)20291061+968
Production code (blanks + comments stripped)1350861+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:

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

ClassWhat it ownsSample API
QueueEngineAll queue state mutations, history, snapshot emission. Pure Kotlin, no Android deps.advance(positionMs), previous(positionMs), addToManualQueue, replaceAutomaticQueue, applyContentEdits, restore, clear
QueueReaderPure 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()
QueueControllerMutations + 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
QueuePayloadStoreThread-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)
QueueExoBridgeObserves 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
QueueBridgeHostPure-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:

Why three classes, not two

The original plan said QueueReader + QueueController. Shipped with a third class, QueuePayloadStore, holding the shared HashMap<key, ExoNotificationData>.

Previous-playback semantics

On previous():

  1. If current playback position > 3 seconds → seek to 0 (don't change item).
  2. Else if history non-empty → push current back to upcoming.first, pop history.last → current.
  3. Else → seek to 0.

Rules:

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.

94 @Test methods by the end (property layer runs many randomized cases per method):

Layer@Test methodsStyle
QueueEngine45Behavior-by-example
Engine invariants3Randomized stateful property test + 2 scoped invariants, covers 11 named invariants (caught 3 latent bugs)
QueueExoBridge10Reconciliation against a list-backed FakeHost
FinishedThreshold14Pure math, boundary cases
QueueController22Orchestration 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

What still looks legacy (intentionally)

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

Commit timeline (origin/develop..dev/OD-7458/queue_overhaul)

DateCommitStory
2026-05-20d97a41fa4Red-phase baseline: 45 failing tests + QueueEngine interface
2026-05-2076642833cGreen phase sans previous()QueueEngineImpl implemented
2026-05-208b264cca8QueueManager wired as facade over QueueEngine
2026-05-2066f79b82aFacade QA fixes: playing panel, next button, drag-reorder
2026-05-219e879682aPR 3 — history + previous() end-to-end
2026-05-22568b6946bNotification + MediaSession previous routed through engine
2026-05-22613bd4602PR 2QueueExoBridge extracted from PlayerManager
2026-05-254af50a32bHarden persistence + drop legacy queue observer
2026-05-26e955d3438Demolish syncMirror; queue becomes a derived snapshot view
2026-05-283745a9c2bHardening QA fixes: next autoplay, catalog leak, previous edge cases
2026-05-2937cf3a4c2Previous resumes at last position; restart if finished
2026-05-30dfaec2e69Property tests for engine invariants + 3 bug fixes
2026-06-01b67661dfaBridge integration tests with list-backed fake host
2026-06-013398035ceExtract FinishedThreshold helper + 12 unit tests
2026-06-023f9df80f7Prune dead code in QueueManager / PlayerManager
2026-06-02d459a6a33Delete object QueueManager; split into Reader + Controller + PayloadStore
2026-06-020070f38bcDocument the deletion outcome
2026-06-0282ad63844Add before/after architecture comparison doc
2026-06-10a767edeb7QueueControllerTest — 22 orchestration tests
2026-06-10b35ccfe76Refresh docs after task #6 ships

(Plus one earlier facade-wire commit. Full bodies in raw/work/queue-overhaul/commits.md.)

Quality rating (self-assessment)

DimensionBeforeAfter
Testability of pure layers1/109/10
Testability of orchestration1/108/10
Separation of concerns2/106.5/10 (Controller bloated to 429 LOC during hardening)
Race safety3/108/10
Feature support (previous, history, resume)1/109/10
Observability4/107/10
Hilt integration0/106/10 (top-level accessors still used)
UI reactivity3/103/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:

Identical prompts, Claude Code default model, one trial per branch.

MetricLegacyRefactoredDelta
Total cost$2.12$0.95−55%
Wall-clock time8m 34s3m 53s−55%
API time11m 42s6m 29s−45%
Haiku output tokens40.3k20.5k−49%
Haiku cache reads9.1M3.8M−58%
Opus output tokens9.6k2.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

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.