From 746e2319b9f24be4b19ac9de8b0fe304229e43db Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:15:44 +0000 Subject: [PATCH] now playing: refresh neighbours when repeat changes The adjacent artwork effect keys on the item and queue layout, but the predictions now also depend on the repeat mode. Toggling repeat left the cached neighbours stale, so the edge swipe still resisted until the track changed. Key the effect on the repeat mode too, with a route test that toggles repeat and expects the wrapped previous art. --- .../subtracks/ui/playback/NowPlayingScreen.kt | 2 +- .../ui/playback/NowPlayingRouteTest.kt | 190 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index f71e054e..f38e52ff 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -244,7 +244,7 @@ fun NowPlayingRoute( name = item.title, ) var adjacent by remember { mutableStateOf(AdjacentArt()) } - LaunchedEffect(state.item?.id, state.layout) { + LaunchedEffect(state.item?.id, state.layout, state.repeat) { if (state.item == null) { adjacent = AdjacentArt() return@LaunchedEffect diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt new file mode 100644 index 00000000..0df29073 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingRouteTest.kt @@ -0,0 +1,190 @@ +package com.subtracks.ui.playback + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.TEST_TIMEOUT_MS +import com.subtracks.awaitUntil +import com.subtracks.cancelAndJoinBlocking +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.download.ArtworkStore +import com.subtracks.data.download.FakeDownloadEngine +import com.subtracks.data.model.Song +import com.subtracks.data.model.Source +import com.subtracks.data.prefs.fakeUserPreferences +import com.subtracks.data.repo.DownloadRepository +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.SourceRepository +import com.subtracks.data.source.ServerActionSink +import com.subtracks.data.source.StarType +import com.subtracks.playback.FakePlayerConnection +import com.subtracks.playback.FakePlayerHandle +import com.subtracks.playback.PlaybackController +import com.subtracks.playback.RepeatMode +import com.subtracks.ui.theme.SubtracksTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.util.concurrent.Executors + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NowPlayingRouteTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher) + + private lateinit var db: SubtracksDatabase + private lateinit var handle: FakePlayerHandle + private lateinit var controller: PlaybackController + private lateinit var library: LibraryRepository + private lateinit var downloads: DownloadRepository + + private val prefs = fakeUserPreferences() + + private val serverActions = + object : ServerActionSink { + override suspend fun nowPlaying(songId: String) = Unit + + override suspend fun scrobble( + songId: String, + time: Long, + ) = Unit + + override suspend fun setStar( + type: StarType, + id: String, + starred: Boolean, + ) = Unit + } + + private fun setUpState() { + val context = ApplicationProvider.getApplicationContext() + val artworkStore = ArtworkStore(File(context.cacheDir, "art")) + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope) + val queues = QueueRepository(db) + downloads = + DownloadRepository( + db, + sources, + FakeDownloadEngine(), + File(context.cacheDir, "downloads-${System.nanoTime()}"), + artworkStore = artworkStore, + artworkFetcher = { ByteArray(0) }, + scope = repoScope, + ).also { it.start() } + library = LibraryRepository(db, sources, serverActions, scope = repoScope) + handle = FakePlayerHandle() + controller = + PlaybackController( + sources, + queues, + FakePlayerConnection(handle), + downloads, + showMessage = {}, + scope = controllerScope, + ) + } + + private fun seedAlbum(count: Int) { + runBlocking { + db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1)) + db.libraryDao().upsertSongs( + (1..count).map { track -> + Song( + sourceId = 1, + id = "s$track", + albumId = "al1", + artistId = "ar1", + title = "Song $track", + album = "Album", + artist = "Artist", + duration = 100, + track = track.toLong(), + disc = 1, + starred = null, + genre = null, + ) + }, + ) + } + } + + @After + fun tearDown() { + cancelAndJoinBlocking(controllerScope, repoScope) + db.close() + dispatcher.close() + } + + @Test + fun togglingRepeatRefreshesTheAdjacentArtwork() { + setUpState() + seedAlbum(3) + controller.playAlbum(1, "al1", 0) + awaitUntil("waiting for the first track") { + controller.state.value.item + ?.id == "s1" + } + + composeRule.setContent { + SubtracksTheme { + NowPlayingRoute( + onBack = {}, + onQueue = {}, + onViewAlbum = {}, + onViewArtist = {}, + setStar = { _, _, _ -> }, + controller = controller, + libraryRepository = library, + downloadRepository = downloads, + userPreferences = prefs, + ) + } + } + composeRule.waitUntil(TEST_TIMEOUT_MS) { + composeRule + .onAllNodesWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule + .onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true) + .assertDoesNotExist() + + controller.cycleRepeat() + assertEquals(RepeatMode.All, controller.state.value.repeat) + + composeRule.waitUntil(TEST_TIMEOUT_MS) { + composeRule + .onAllNodesWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + } + } +}