Remove Browse and Queue Implementation Plan
Remove Browse and Queue Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Delete the Music Assistant browse and queue surfaces so no library, search, or queue UI remains reachable, and the Android Auto tree offers SendSpin server selection only.
Architecture: Pure deletion in dependency order. ui/navigation is already orphaned and goes first. The Android Auto tree loses its MA branches but keeps server selection. Removing ui/queue then severs the single edge keeping ui/detail alive, so ui/detail goes last. Nothing is built.
Tech Stack: Kotlin, Jetpack Compose, JUnit4, MockK, Robolectric, Media3 MediaLibraryService
Spec: docs/superpowers/specs/2026-09-01-sendspin-only-player.md
Global Constraints
- No emojis in code, logs, or UI strings. ASCII only:
usnot the micro sign,->not an arrow glyph,+/-not the plus-minus glyph. - No self-citation in commits or comments. No mention of Claude or AI, no Co-Authored-By lines.
- Build command prefix:
cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew <task> - Every task ends with
:app:compileDebugKotlinand:app:testDebugUnitTestpassing. - NO VESTIGIAL STUBS. Deleting a concept means deleting its declaration, not emptying it. No parameter nothing reads, no lambda or nullable default that swallows a call, no
if (true). Collapse every conditional to its surviving branch. - Clean up orphans YOUR change creates. Leave pre-existing dead code alone and note it instead. Verify with a repo-wide grep before deciding which case applies.
- Do NOT touch
com.sendspindroid.musicassistantorcom.sendspindroid.remote. They are load-bearing for playback, artwork and server setup, and are owned by later plans. - Unit tests that read source files use paths relative to the
android/appmodule directory.
Three lessons from the previous phase, which apply to every task here
- Line numbers are locators, not addresses. Every number below was read at the start of this plan and shifts as you delete. Locate by symbol name and re-grep after each deletion.
- Kotlin default arguments hide dead chains.
onBrowseLibrary: () -> Unit = {},showPlayerButton: Boolean = falseandqueueViewModel: QueueViewModel? = nullall keep compiling after their only real supplier is deleted, so nothing errors and the feature silently cannot activate. When you delete a supplier, follow the parameter to every declaration and delete the parameter too – do not leave the default behind. - A file can mix concerns.
AutoBrowseTree.ktholds SendSpin server selection AND MA library browse in one 253-line file. Grepping for a Music Assistant symbol finds the MA half and tells you nothing about what sits beside it. Read whole files before deleting from them.
Task 1: Delete the orphaned browse package
com.sendspindroid.ui.navigation (4,132 lines: home, library, browse, playlists, search) has no production caller. One test imports it.
Files:
- Delete:
android/app/src/main/java/com/sendspindroid/ui/navigation/(entire package, 14 files) - Delete:
android/app/src/test/java/com/sendspindroid/ui/compose/SearchScreenResultsTest.kt - Test:
android/app/src/test/java/com/sendspindroid/ui/BrowsePackagesGoneTest.kt
Interfaces:
- Consumes: nothing.
-
Produces:
com.sendspindroid.ui.navigationno longer exists. Task 4 extends the same test to coverui.detail. - Step 1: Confirm the package is genuinely orphaned
grep -rn "import com.sendspindroid.ui.navigation" android/app/src --include=*.kt | grep -v "/ui/navigation/"
Expected: exactly two hits – ui/compose/SearchScreenResultsTest.kt:8 and a string literal inside ui/NowPlayingRootTest.kt (that one is a test assertion, not an import; leave it). If you find a production caller, STOP and report it as a decision point.
- Step 2: Write the failing test
Create android/app/src/test/java/com/sendspindroid/ui/BrowsePackagesGoneTest.kt:
package com.sendspindroid.ui
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* SendSpin defines no library, browse, or search concept, so the packages that
* implemented those against the Music Assistant API have no protocol
* counterpart and must not exist.
*
* Task 4 extends this to ui/detail once the queue surface stops importing it.
*/
class BrowsePackagesGoneTest {
@Test
fun navigationPackageIsDeleted() {
val dir = File("src/main/java/com/sendspindroid/ui/navigation")
assertFalse("ui/navigation must be deleted, found " + dir.absolutePath, dir.exists())
}
@Test
fun nothingImportsTheNavigationPackage() {
val roots = listOf(File("src/main/java"), File("src/test/java"))
val offending = roots
.filter { it.isDirectory }
.flatMap { it.walkTopDown().filter { f -> f.isFile && f.name.endsWith(".kt") } }
.flatMap { file ->
file.readLines()
.map { it.trim() }
.filter { it.startsWith("import com.sendspindroid.ui.navigation") }
.map { file.name + ": " + it }
}
assertEquals("nothing may import ui.navigation", emptyList<String>(), offending)
}
}
- Step 3: Run it to make sure it fails
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*BrowsePackagesGoneTest*"
Expected: both FAIL – the directory exists and SearchScreenResultsTest.kt imports from it.
- Step 4: Delete the package and its test
git rm -r android/app/src/main/java/com/sendspindroid/ui/navigation
git rm android/app/src/test/java/com/sendspindroid/ui/compose/SearchScreenResultsTest.kt
SearchScreenResultsTest tests SearchViewModel.SearchState, a type being deleted with the package. There is nothing to rewrite it against, so it goes rather than being ported.
- Step 5: Run the test to verify it passes
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*BrowsePackagesGoneTest*"
Expected: PASS, 2 tests.
- Step 6: Sweep resources the deletion orphaned
The deleted screens were the last users of several drawables and strings. Derive the candidate list mechanically rather than guessing – extract every resource the deleted files referenced, then keep only those with zero references remaining:
# 1. Extract every resource name the deleted files referenced (from git, since they are gone)
git show HEAD -- android/app/src/main/java/com/sendspindroid/ui/navigation | grep -oE "R\.(string|drawable)\.[A-Za-z0-9_]+" | sort -u > /tmp/candidates.txt
# 2. For each candidate, count references in the CURRENT tree
while read -r ref; do
name="${ref##*.}"
kind=$(echo "$ref" | cut -d. -f2)
n=$(grep -rl "R\.$kind\.$name\b\|@$kind/$name\b" android/app/src --include=*.kt --include=*.xml 2>/dev/null | wc -l)
[ "$n" -eq 0 ] && echo "ORPHANED: $kind/$name"
done < /tmp/candidates.txt
Delete only the names that print as ORPHANED. ic_nav_home, ic_nav_library and
ic_nav_search were used by these screens – expect them in the list, but let the
check decide rather than assuming.
Keep the word-boundary markers shown in the grep pattern: a substring match makes ic_nav_home look live
when only ic_nav_home_selected survives, and the reverse.
- Step 7: Verify build and full suite
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
Expected: BUILD SUCCESSFUL.
- Step 8: Commit
git add -A android/app/src
git commit -m "refactor(ui): delete the orphaned browse package"
Task 2: Drop the Music Assistant branches from the Android Auto tree
playback/AutoBrowseTree.kt mixes two concerns. SendSpin server selection stays – it is the only way to choose a server from a car. The MA library branches go.
KEEP: MEDIA_ID_ROOT, MEDIA_ID_DISCOVERED, MEDIA_ID_SERVER_PREFIX, MEDIA_ID_SAVED_SERVER_PREFIX, MEDIA_ID_MESSAGE_PREFIX, MEDIA_ID_MESSAGE_NO_SERVERS, and every code path serving them.
DELETE: MEDIA_ID_MA_PLAYLISTS, MEDIA_ID_MA_ALBUMS, MEDIA_ID_MA_ARTISTS, MEDIA_ID_MA_RADIO, MEDIA_ID_MA_PLAYLIST_PREFIX, MEDIA_ID_MA_ALBUM_PREFIX, MEDIA_ID_MA_ARTIST_PREFIX, MEDIA_ID_MA_TRACK_PREFIX, MEDIA_ID_MA_RADIO_ITEM_PREFIX, MEDIA_ID_MA_QUEUE_ITEM_PREFIX, their tree nodes, their empty-state messages, and the PlaybackService fetch helpers behind them.
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/playback/AutoBrowseTree.kt(253 lines) - Modify:
android/app/src/main/java/com/sendspindroid/playback/PlaybackService.kt(4,285 lines) – theonGetChildren/onGetItem/onSearchMA branches, theMusicAssistant.getPlaylists/getAlbums/getArtists/getRadioStations/searchhelpers, and the voice-search path at roughly :3783-3835 - Delete:
android/app/src/main/java/com/sendspindroid/playback/AutoVoiceSearch.kt(Music Assistant library search for Android Auto voice commands – see Step 3) - Test:
android/app/src/test/java/com/sendspindroid/playback/AutoBrowseTreeTest.kt
Interfaces:
- Consumes: nothing from Task 1.
- Produces: an Auto tree with server selection only.
PlaybackServiceno longer calls any Music Assistant library method. It still importsmusicassistantfor artwork, queue prefetch and connection state – that is expected and owned by a later plan.
Read AutoBrowseTree.kt in full before editing. It is 253 lines and mixes both concerns.
- Step 1: Write the failing test
Create android/app/src/test/java/com/sendspindroid/playback/AutoBrowseTreeTest.kt:
package com.sendspindroid.playback
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Android Auto keeps SendSpin server selection -- it is the only way to choose
* or switch a server from a car -- but must not offer a Music Assistant
* library. SendSpin's protocol has no library, so those branches had no
* counterpart to keep.
*/
class AutoBrowseTreeTest {
private fun treeSource(): String =
File("src/main/java/com/sendspindroid/playback/AutoBrowseTree.kt").readText()
@Test
fun serverSelectionSurvives() {
val src = treeSource()
val missing = listOf(
"MEDIA_ID_ROOT",
"MEDIA_ID_DISCOVERED",
"MEDIA_ID_SERVER_PREFIX",
"MEDIA_ID_SAVED_SERVER_PREFIX",
"MEDIA_ID_MESSAGE_NO_SERVERS"
).filterNot { src.contains(it) }
assertEquals("server selection must survive in the Auto tree", emptyList<String>(), missing)
}
@Test
fun musicAssistantBranchesAreGone() {
val src = treeSource()
val offending = listOf(
"MEDIA_ID_MA_PLAYLISTS",
"MEDIA_ID_MA_ALBUMS",
"MEDIA_ID_MA_ARTISTS",
"MEDIA_ID_MA_RADIO",
"MEDIA_ID_MA_PLAYLIST_PREFIX",
"MEDIA_ID_MA_ALBUM_PREFIX",
"MEDIA_ID_MA_ARTIST_PREFIX"
).filter { src.contains(it) }
assertEquals("MA library branches must be gone from the Auto tree", emptyList<String>(), offending)
}
@Test
fun playbackServiceCallsNoLibraryMethods() {
val src = File("src/main/java/com/sendspindroid/playback/PlaybackService.kt").readText()
val offending = listOf(
"MusicAssistant.getPlaylists",
"MusicAssistant.getAlbums",
"MusicAssistant.getArtists",
"MusicAssistant.getRadioStations",
"MusicAssistant.search"
).filter { src.contains(it) }
assertEquals("PlaybackService must not call MA library methods", emptyList<String>(), offending)
}
@Test
fun playbackServiceStillServesServerBrowsing() {
val src = File("src/main/java/com/sendspindroid/playback/PlaybackService.kt").readText()
assertTrue("onGetChildren must survive for server selection", src.contains("onGetChildren"))
assertTrue("the discovered-servers branch must survive", src.contains("MEDIA_ID_DISCOVERED"))
}
}
- Step 2: Run it to make sure it fails
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*AutoBrowseTreeTest*"
Expected: musicAssistantBranchesAreGone and playbackServiceCallsNoLibraryMethods FAIL. The two survival tests PASS already – they are guards against over-deletion, not drivers.
- Step 3: Strip the MA branches
In AutoBrowseTree.kt: delete the MA constants, the tree nodes that expose Playlists / Albums / Artists / Radio as browsable children of root, and the MA arms of the empty-state message function. Keep the server-selection nodes and the no-servers message.
In PlaybackService.kt: delete the onGetChildren and onGetItem branches that match MA media IDs, the onSearch implementation if it only ever searched Music Assistant, and the private helpers that call MusicAssistant.getPlaylists / getAlbums / getArtists / getRadioStations / search. Delete the MA media-ID constants mirrored at the top of the file.
If onSearch becomes an override whose body no longer does anything meaningful, delete the override entirely rather than leaving it returning an empty result – an empty override is a vestigial stub.
Android Auto voice search goes with it. This is not optional and the test above
forces it: playbackServiceCallsNoLibraryMethods asserts MusicAssistant.search is
absent, and the voice-search path is what calls it.
playback/AutoVoiceSearch.ktimportscom.sendspindroid.musicassistant.SearchResultsand exposespickFromResults,unavailableMessageandnoRecentTracksMessage.PlaybackService.ktdrives it at roughly :3783-3835 – theonSearch/ voice-command path that turns “play X” from a car into a Music Assistant library search.
Delete AutoVoiceSearch.kt and the PlaybackService voice-search path together. Then
check whether notifyVoiceSearchError and any voice-search-only string resources are
left with zero callers, and delete those too.
Why it cannot be kept: voice search is Music Assistant LIBRARY search. SendSpin’s protocol defines no search message and no library, so there is nothing for a SendSpin-native voice search to query. It only ever worked while MA was connected. There is no port, only deletion.
Keep the MediaSession voice controls that do not need a library – play, pause, next, previous. Those are transport commands, not search, and they continue to work in Auto.
Do NOT remove MusicAssistant.initialize, queueUpdates, connectionState, MaProxyImageFetcher, or the DataChannel calls. Those serve artwork, prefetch and connection state, and belong to later plans.
- Step 4: Run the test to verify it passes
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*AutoBrowseTreeTest*"
Expected: PASS, 4 tests.
- Step 5: Verify build and full suite
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
Expected: BUILD SUCCESSFUL. Any existing Auto-browse test that asserted MA branches must be updated to the new tree, not deleted wholesale – report which you touched.
- Step 6: Commit
git add -A android/app/src
git commit -m "refactor(auto): drop Music Assistant branches from the browse tree"
Task 3: Delete the queue surface
com.sendspindroid.ui.queue (1,794 lines, 4 files) is the live queue UI. SendSpin defines no viewable queue, and SaveQueueAsPlaylistDialog writes to a Music Assistant playlist – a library-write feature outside the player role. Deleting it also severs the single edge keeping ui/detail alive, which Task 4 needs.
Files:
- Delete:
android/app/src/main/java/com/sendspindroid/ui/queue/(QueueSheetContent.kt, QueueSheetFragment.kt, QueueViewModel.kt, SaveQueueAsPlaylistDialog.kt) - Delete:
android/app/src/test/java/com/sendspindroid/ui/compose/QueueSheetSwipeTest.kt - Delete (conditional):
android/app/src/main/java/com/sendspindroid/ui/main/components/QueueButton.kt - Modify:
android/app/src/main/java/com/sendspindroid/ui/AppShell.kt - Modify:
android/app/src/main/java/com/sendspindroid/ui/main/NowPlayingScreen.kt - Modify:
android/app/src/main/java/com/sendspindroid/ui/main/NowPlayingHeadUnit.kt - Modify:
android/app/src/main/java/com/sendspindroid/MainActivity.kt - Modify:
android/app/src/main/java/com/sendspindroid/ui/adaptive/AdaptiveDefaults.kt - Test:
android/app/src/test/java/com/sendspindroid/ui/QueueSurfaceGoneTest.kt
Interfaces:
- Consumes: nothing from Tasks 1-2.
- Produces:
com.sendspindroid.ui.queueno longer exists, andui.detailhas zero importers, which is Task 4’s precondition.
The wiring to remove, mapped from the caller side:
| File | What references the queue |
|---|---|
AppShell.kt |
QueueViewModel import, onQueueClick parameter (twice), nowPlayingQueueVisible state, the queue toggle button and its showQueueToggle gate, the queueViewModel instance, showQueueViewModel |
NowPlayingScreen.kt |
QueueButton import, QueueSheetContent and QueueViewModel imports, onQueueClick parameter, queueViewModel: QueueViewModel? = null parameter, the LaunchedEffect(queueViewModel) block, inlineQueueViewModel, showQueueButton, queueVisible, inlineQueueVisible |
NowPlayingHeadUnit.kt |
QueueUiState and QueueViewModel imports |
MainActivity.kt |
QueueSheetFragment import, showQueueSheet(), the onQueueClick = { showQueueSheet() } lambda, and the call at :729 |
AdaptiveDefaults.kt |
showInlineQueuePanel, hasTvQueueSidebar, showBrowseQueueSidebar – delete each ONLY if it ends with zero callers |
playback/PlaybackService.kt |
the Android Auto now-playing queue – MEDIA_ID_MA_QUEUE_ITEM_PREFIX, populatePlayerQueue(), createMaQueueMediaItem(), and the onGetItem / playback branches that resolve that prefix. Reassigned here from Task 2; see below |
The Android Auto now-playing queue comes out here too. Task 2 left it in place
because a MediaSession queue is a different mechanism from a library browse tree, which
was a reasonable reading. But it is built from MaQueueItem – Music Assistant data –
and SendSpin supplies no queue state to replace it. Leaving it would mean Android Auto
still shows an MA-sourced queue after this task removes the in-app queue for exactly
that reason.
Keep MusicAssistant.queueUpdates itself: PlaybackService also uses it to prefetch
about a second before a track change, which is a playback optimisation rather than a
queue surface. Delete only the Auto queue construction that consumes it. If removing
the Auto queue leaves queueUpdates with no remaining consumer, say so in your report
rather than deleting it – a later plan owns that call.
Note queueViewModel: QueueViewModel? = null in NowPlayingScreen. That nullable default is exactly the shape that hides a dead chain – delete the parameter, not just its supplier.
- Step 1: Write the failing test
Create android/app/src/test/java/com/sendspindroid/ui/QueueSurfaceGoneTest.kt:
package com.sendspindroid.ui
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* SendSpin defines no viewable queue, so the queue surface has no protocol
* counterpart. SaveQueueAsPlaylistDialog additionally wrote to a Music
* Assistant playlist, a library-write feature outside the player role.
*/
class QueueSurfaceGoneTest {
@Test
fun queuePackageIsDeleted() {
val dir = File("src/main/java/com/sendspindroid/ui/queue")
assertFalse("ui/queue must be deleted, found " + dir.absolutePath, dir.exists())
}
@Test
fun nothingImportsTheQueuePackage() {
val roots = listOf(File("src/main/java"), File("src/test/java"))
val offending = roots
.filter { it.isDirectory }
.flatMap { it.walkTopDown().filter { f -> f.isFile && f.name.endsWith(".kt") } }
.flatMap { file ->
file.readLines()
.map { it.trim() }
.filter { it.startsWith("import com.sendspindroid.ui.queue") }
.map { file.name + ": " + it }
}
assertEquals("nothing may import ui.queue", emptyList<String>(), offending)
}
@Test
fun nowPlayingScreenStillRenders() {
val src = File("src/main/java/com/sendspindroid/ui/AppShell.kt").readText()
assertTrue("AppShell must still render NowPlayingScreen", src.contains("NowPlayingScreen("))
}
@Test
fun transportControlsSurvive() {
val src = File("src/main/java/com/sendspindroid/ui/main/NowPlayingScreen.kt").readText()
val missing = listOf(
"onPlayPauseClick",
"onNextClick",
"onPreviousClick",
"onVolumeChange"
).filterNot { src.contains(it) }
assertEquals("transport controls must survive", emptyList<String>(), missing)
}
}
- Step 2: Run it to make sure it fails
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*QueueSurfaceGoneTest*"
Expected: queuePackageIsDeleted and nothingImportsTheQueuePackage FAIL. The two survival tests PASS already – they guard against over-deletion.
- Step 3: Unwire the queue, caller side first
Work outside-in so you never delete a definition that still has a caller. Remove the wiring in MainActivity.kt, AppShell.kt, NowPlayingHeadUnit.kt, then NowPlayingScreen.kt, using the table above. For each conditional that depended on queue visibility, collapse to the surviving branch rather than leaving a tautology.
Then delete the package and its test:
git rm -r android/app/src/main/java/com/sendspindroid/ui/queue
git rm android/app/src/test/java/com/sendspindroid/ui/compose/QueueSheetSwipeTest.kt
Then check the conditional deletions:
grep -rn "QueueButton" android/app/src/main
grep -rn "showInlineQueuePanel\|hasTvQueueSidebar\|showBrowseQueueSidebar" android/app/src/main
Delete each declaration that returns no callers. If one still has a caller, leave it and say which in your report.
- Step 4: Run the test to verify it passes
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*QueueSurfaceGoneTest*"
Expected: PASS, 4 tests.
- Step 5: Verify build and full suite
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
Expected: BUILD SUCCESSFUL.
- Step 6: Sweep orphaned resources
The queue sheet owned several strings, including queue_empty_subtitle, which reads “Add songs from your library or search to start playing” and refers to surfaces that no longer exist. Run the same mechanical sweep as Task 1 Step 6, substituting ui/queue for ui/navigation in the extraction command.
- Step 7: Commit
git add -A android/app/src
git commit -m "refactor(ui): delete the queue surface"
Task 4: Delete the detail package
With the queue gone, com.sendspindroid.ui.detail (4,246 lines) has no importer. Its only remaining tie was ui/queue/SaveQueueAsPlaylistDialog.kt importing ui.detail.components.BulkAddState.
Files:
- Delete:
android/app/src/main/java/com/sendspindroid/ui/detail/(entire package, 17 files) - Modify:
android/app/src/test/java/com/sendspindroid/ui/BrowsePackagesGoneTest.kt(extend from Task 1)
Interfaces:
- Consumes: a codebase where
ui/queueno longer exists (Task 3). -
Produces:
com.sendspindroid.ui.detailno longer exists. - Step 1: Confirm it is now orphaned
grep -rn "import com.sendspindroid.ui.detail" android/app/src --include=*.kt | grep -v "/ui/detail/"
Expected: only the string literal inside ui/NowPlayingRootTest.kt, which is a test assertion rather than an import. If a real importer remains, STOP and report it – Task 3 left something behind.
- Step 2: Extend the failing test
Add these two methods to BrowsePackagesGoneTest.kt, keeping the existing ones:
@Test
fun detailPackageIsDeleted() {
val dir = File("src/main/java/com/sendspindroid/ui/detail")
assertFalse("ui/detail must be deleted, found " + dir.absolutePath, dir.exists())
}
@Test
fun nothingImportsTheDetailPackage() {
val roots = listOf(File("src/main/java"), File("src/test/java"))
val offending = roots
.filter { it.isDirectory }
.flatMap { it.walkTopDown().filter { f -> f.isFile && f.name.endsWith(".kt") } }
.flatMap { file ->
file.readLines()
.map { it.trim() }
.filter { it.startsWith("import com.sendspindroid.ui.detail") }
.map { file.name + ": " + it }
}
assertEquals("nothing may import ui.detail", emptyList<String>(), offending)
}
- Step 3: Run it to make sure it fails
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*BrowsePackagesGoneTest*"
Expected: detailPackageIsDeleted FAILS. nothingImportsTheDetailPackage should already pass if Task 3 was complete – if it fails, that names the leftover importer.
- Step 4: Delete the package
git rm -r android/app/src/main/java/com/sendspindroid/ui/detail
- Step 5: Run the test to verify it passes
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*BrowsePackagesGoneTest*"
Expected: PASS, 4 tests.
- Step 6: Sweep orphaned resources and verify the suite
Run the same mechanical sweep as Task 1 Step 6, substituting ui/detail for ui/navigation in the extraction command.
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
Expected: BUILD SUCCESSFUL.
- Step 7: Commit
git add -A android/app/src
git commit -m "refactor(ui): delete the detail package"
Task 5: Delete the orphaned player queue machinery
Task 3 deleted populatePlayerQueue(), which was the only production caller of
SendSpinPlayer.updateQueueItems(). That orphaned the whole native-queue Timeline
path, but Task 3 deliberately did not cascade into it: SendSpinPlayer.kt is the
MediaSession Player bridge this project treats as playback-critical, and the end of
a 2,756-line deletion was the wrong moment to touch it unbriefed.
It is now dead code with a passing test suite, which is worse than plain dead code. Roughly 11 green tests across three files exercise machinery that can never execute, so any “is this referenced?” search finds callers and concludes it is live, and deleting it later looks like reducing coverage.
Verified unreachable (re-verify before deleting – intervening work may have added
a caller): queueMediaItems is assigned non-empty at exactly one site, inside
updateQueueItems. Every other assignment sets it to emptyList(). So all seven
queueMediaItems.isNotEmpty() guards are permanently false, MultiItemTimeline is
constructible only behind that dead feed, and onQueueItemSelected has no production
setter.
Files:
- Modify:
android/app/src/main/java/com/sendspindroid/playback/SendSpinPlayer.kt - Modify or delete:
android/app/src/test/java/com/sendspindroid/playback/SendSpinPlayerTest.kt - Modify or delete:
android/app/src/test/java/com/sendspindroid/e2e/BrowseMaLibraryQueueTest.kt - Modify or delete:
android/app/src/test/java/com/sendspindroid/e2e/AndroidAutoBrowseTreeTest.kt
Interfaces:
- Consumes: a codebase where
populatePlayerQueueno longer exists (Task 3). -
Produces:
SendSpinPlayerwith single-item Timeline handling only. - Step 1: Re-verify it is still unreachable
grep -rn "updateQueueItems\|onQueueItemSelected\|MultiItemTimeline\|queueMediaItems" android/app/src/main --include=*.kt
Expected: hits only inside SendSpinPlayer.kt itself. If any other production file
appears, STOP and report it – something re-introduced a caller and the premise no
longer holds.
- Step 2: Write the failing test
Add to android/app/src/test/java/com/sendspindroid/playback/SendSpinPlayerTest.kt:
@Test
fun timelineIsAlwaysSingleItem() {
val source = java.io.File("src/main/java/com/sendspindroid/playback/SendSpinPlayer.kt").readText()
val offending = listOf(
"updateQueueItems",
"onQueueItemSelected",
"MultiItemTimeline",
"queueMediaItems"
).filter { source.contains(it) }
org.junit.Assert.assertEquals(
"SendSpin supplies no queue state, so the player has no multi-item timeline",
emptyList<String>(),
offending
)
}
- Step 3: Run it to make sure it fails
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*SendSpinPlayerTest*"
Expected: FAIL listing all four symbols.
- Step 4: Delete the machinery
In SendSpinPlayer.kt delete onQueueItemSelected, updateQueueItems, queueMediaItems, currentQueueIndex and MultiItemTimeline. Collapse each of the seven queueMediaItems.isNotEmpty() guards to its else branch rather than leaving if (false).
Then handle the tests. For each of the three files, keep any test that still exercises
surviving single-item behaviour and delete only the cases that drove the queue path.
Two of the files also carry titles and KDoc describing a world that no longer exists –
BrowseMaLibraryQueueTest’s header describes “Browse MA library -> select album ->
verify queue” and AndroidAutoBrowseTreeTest’s manual steps end at “Tap queue button
-> verify queue is populated”. Fix or remove those headers; a stale test title is how
the next reader is misled about what the suite covers.
- Step 5: Run the test to verify it passes
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:testDebugUnitTest --tests "*SendSpinPlayerTest*"
Expected: PASS.
- Step 6: Verify build and full suite
Run: cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest
Expected: BUILD SUCCESSFUL. Report the new test count and which tests you removed – the total will drop, and that drop must be explainable as “these exercised unreachable code”, not as lost coverage.
- Step 7: Commit
git add -A android/app/src
git commit -m "refactor(playback): delete the orphaned player queue machinery"
Task 6: Device verification
Tasks 1-4 are verified by compilation and unit tests. This confirms the app still works on hardware and that Android Auto kept its server selection.
Files:
- Modify:
docs/superpowers/plans/2026-09-01-remove-browse-and-queue.md(append the verification record)
Interfaces:
- Consumes: the app produced by Tasks 1-4.
-
Produces: a verification record appended to this plan.
- Step 1: Build and install
cd android && JAVA_HOME="C:/Program Files/Android/Android Studio/jbr" ./gradlew :app:assembleDebug
"$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe" devices -l
"$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe" -s <serial> install -r android/app/build/outputs/apk/debug/app-debug.apk
A Relndoo T901_US tablet has been used for this project, serial T901YCU250305206. Pass -s <serial> on every adb command – a stale offline emulator is also attached and bare commands fail with “more than one device”. In Git Bash, prefix any command containing a device path with MSYS_NO_PATHCONV=1.
- Step 2: Capture logs
A="$LOCALAPPDATA/Android/Sdk/platform-tools/adb.exe -s <serial>"
$A logcat -c
$A logcat -G 16M
$A logcat -v threadtime > browse-verify.txt &
$A shell am start -n com.sendspindroid/.MainActivity
The 16M buffer matters – the 256 KiB default overflows during playback and loses the transitions being verified. Delete browse-verify.txt when finished; do not commit it.
- Step 3: Verify the player still works
Connect to a SendSpin server and play. Confirm Now Playing renders with artwork and metadata, and that play, pause, next, previous and volume each act on the server – check logcat for the outgoing command and the server response, not just the UI reacting.
- Step 4: Verify no queue surface remains
Confirm there is no queue button, no queue sheet, and no inline queue panel on any layout. Check tablet portrait and landscape, since the inline panel was tablet-only.
- Step 5: Verify Android Auto
Using Android Auto’s Desktop Head Unit or a real head unit if available, confirm the browse tree still lists discovered and saved SendSpin servers and that selecting one connects, and that no Playlists / Albums / Artists / Radio entries appear. If no head unit is available, say so plainly and mark this NOT VERIFIED rather than assuming.
- Step 6: Check for crashes
grep -E "FATAL EXCEPTION|AndroidRuntime" browse-verify.txt
Report anything found.
- Step 7: Record the result and commit
Append a verification record to this plan: device model, Android version, what was exercised, PASS/FAIL per item, what could not be verified, and any defect found.
git add docs/superpowers/plans/2026-09-01-remove-browse-and-queue.md
git commit -m "docs(plan): record browse and queue removal device verification"
Completion
At the end of this plan com.sendspindroid.ui.navigation, com.sendspindroid.ui.queue and com.sendspindroid.ui.detail no longer exist, and Android Auto offers SendSpin server selection with no Music Assistant library.
com.sendspindroid.musicassistant still exists and is still load-bearing. PlaybackService continues to use it for artwork (MaProxyImageFetcher), queue prefetch (queueUpdates) and connection state; SendSpinApp uses MaSettings; the add-server wizard uses MaEndpoint. Verify this list with a fresh grep before planning against it.
Remaining after this plan:
- Plan A – cut remote/proxy:
app/remote/,shared/remote/, theio.getstream:stream-webrtc-androiddependency,SignalingClient,MaProxyImageFetcher, the MA API DataChannel plumbing,SendSpin.kt’s four MA-aware lines, and the wizard’s Proxy/Remote modes. - Plan C – retire whatever survives in
musicassistant/once Plan A and this plan are both done.
Device verification record (Task 6)
Verified 8ba3dcf (refactor(playback): fix round 1 for queue-machinery deletion), the tip
of feat/remove-browse-queue at the time of this run. Device: Relndoo T901_US tablet,
Android 15 (API 35), serial T901YCU250305206, USB-connected. Server: Music Assistant at
ws://10.0.2.8:8927/sendspin (“MA Production”). Build: :app:assembleDebug, BUILD
SUCCESSFUL; installed with adb install -r. Logcat captured with logcat -G 16M, deleted
after this record was written. Full detail in
.superpowers/sdd/2026-09-01-remove-browse-and-queue/task-6-report.md.
| Item | Result | Evidence |
|---|---|---|
| Disconnected root = server picker, no tab bar/nav rail | PASS | Fresh-launch screenshot: “Welcome to SendspinDroid”, Quick Connect list, no chrome |
| Connected: Now Playing root, artwork/title/artist/album, progress advances | PASS | Screenshots 5s apart show elapsed time 1:30 -> 1:48 during playback |
| Transport: pause | PASS | SendSpinPlayer: setPlayWhenReady: false -> server group/update {state:"stopped"} |
| Transport: play | PASS | SendSpinPlayer: setPlayWhenReady: true -> server group/update {state:"playing"} + stream/start |
| Transport: next | PASS | Custom command: com.sendspindroid.NEXT -> server stream/end/stream/start + new track metadata |
| Transport: previous | PASS | Custom command: com.sendspindroid.PREVIOUS -> server stream/end/stream/start |
| Transport: volume | PASS | Custom command: com.sendspindroid.SET_VOLUME, SendSpin: setVolume: 80% -> server server/state {controller.volume:80} |
| Skip buttons enabled and functional | PASS | Visibly enabled every screenshot; both produced full server round-trips |
| No queue surface (portrait + landscape) | PASS | No queue button/sheet/panel in the rendered Compose UI in either orientation |
| No browse surface | PASS | Overflow menu = Stats for Nerds, Edit Server, Switch Server, App Settings, Exit App only |
| No crashes | PASS | 0 FATAL EXCEPTION/AndroidRuntime across ~7.5 min, 15,599 log lines |
| Android Auto | NOT VERIFIED | No Android Auto companion app installed on the device; DHU had nothing to connect to. Static read of AutoBrowseTree.kt shows only server-discovery/selection code, no playlist/album/artist/radio/search branches – consistent with intent but not a live substitute |
| Form factors | Portrait + landscape verified; no foldable/TV/head-unit hardware available | Landscape: two-column layout, no queue panel, no crash |
Non-defect notes:
- Fresh install showed
Cannot start auto-reconnect: no server info available, contrary to the expectation of an already-paired auto-connect; connected manually via the visible Quick Connect button instead. This is connection-persistence behavior, outside the scope of the five browse/queue-removal tasks. - Investigated and cleared a static-source finding:
MainActivity.kt’sobserveMaConnectionState()still toggles a legacy XMLqueueButton(plusfavoriteButton/bottomNavigation) based onMusicAssistant.connectionState. This was already found and deliberately left by Task 3 (task-3-report.mdlines 131-147) becausesetupComposeShell()unconditionally detaches the entire legacycoordinatorLayoutfrom the view tree at startup. Independently confirmed both in source (MainActivity.kt:788) and live (logcat:Compose shell: replacing content viewat launch and on every rotation, before any interaction) – the legacy queue button never enters the rendered tree. Not a defect. - Did not exercise the documented pre-existing backgrounding defect (issue #254); all transport tests ran on a single fresh launch per instruction.
No defects found in the work under test.