From 3e260aa17091031e22fab5593998497aca47e5b9 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:22:24 +0000 Subject: [PATCH] address review: dedupe and trim genres, add a starred queue reference, fix the offline source switch --- .../com/subtracks/data/db/LibraryDao.kt | 24 ++++++++++--------- .../com/subtracks/data/db/Migrations.kt | 4 ++-- .../kotlin/com/subtracks/data/db/QueueDao.kt | 21 ++++++++++++++++ .../kotlin/com/subtracks/data/model/Models.kt | 2 +- .../subtracks/data/repo/LibraryRepository.kt | 14 ++++------- .../subtracks/data/repo/QueueRepository.kt | 17 ++++++++++++- .../com/subtracks/data/sync/SyncService.kt | 2 +- .../subtracks/playback/PlaybackController.kt | 21 ++++++++-------- .../com/subtracks/ui/home/HomeViewModel.kt | 4 ++-- .../com/subtracks/ui/library/LibraryScreen.kt | 6 ++++- .../subtracks/ui/playback/NowPlayingScreen.kt | 1 + .../subtracks/data/db/LibraryHomeDaoTest.kt | 21 ++++++++++++++++ .../com/subtracks/data/db/MigrationsTest.kt | 19 +++++++++++++++ .../data/repo/QueueRepositoryTest.kt | 18 ++++++++++++++ .../subtracks/data/sync/SyncServiceTest.kt | 21 ++++++++++++++-- .../playback/PlaybackControllerTest.kt | 2 ++ 16 files changed, 157 insertions(+), 40 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 5ce2eff2..e7ede2be 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -801,6 +801,19 @@ interface LibraryDao { id: String, ): Long + @Query( + "SELECT COUNT(*) FROM songs s " + + "WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " + + "AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " + + "OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " + + "AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " + + "OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))", + ) + suspend fun starredSongOrdinal( + sourceId: Long, + id: String, + ): Long + @Query( "SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " + "ORDER BY year DESC, name COLLATE NOCASE, id", @@ -859,17 +872,6 @@ interface LibraryDao { songId: String, ): Flow> - @Query( - "SELECT DISTINCT song_genres.genre FROM song_genres " + - "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + - "WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " + - "ORDER BY song_genres.genre COLLATE NOCASE", - ) - fun albumGenres( - sourceId: Long, - albumId: String, - ): Flow> - @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id") fun albumsForArtist( sourceId: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index 9af5d9cd..c48601f3 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -344,8 +344,8 @@ val MIGRATION_23_24 = override suspend fun migrate(connection: SQLiteConnection) { connection.execSQL( "INSERT INTO `song_genres` (`sourceId`, `songId`, `position`, `genre`) " + - "SELECT `sourceId`, `id`, 0, `genre` FROM `songs` " + - "WHERE `genre` IS NOT NULL AND `genre` != '' " + + "SELECT `sourceId`, `id`, 0, trim(`genre`) FROM `songs` " + + "WHERE `genre` IS NOT NULL AND trim(`genre`) != '' " + "AND NOT EXISTS (SELECT 1 FROM `song_genres` sg " + "WHERE sg.`sourceId` = `songs`.`sourceId` AND sg.`songId` = `songs`.`id`)", ) diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index 08a75a32..423236a2 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -254,6 +254,27 @@ interface QueueDao { @Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'") suspend fun downloadedLength(sourceId: Long): Long + @Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL") + suspend fun starredLength(sourceId: Long): Long + + @Query( + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " + + "ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset", + ) + suspend fun starredSongs( + sourceId: Long, + offset: Long, + limit: Int, + ): List + + @Query( + "SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " + + "ORDER BY starred DESC, title COLLATE NOCASE, id", + ) + suspend fun starredSongIds(sourceId: Long): List + @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 7bfe0df0..c309049b 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -316,7 +316,7 @@ data class DiscKey( val disc: Long, ) -enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded } +enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred } @Entity( tableName = "queue_entries", diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index a7e840d5..f0b8eca2 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -88,12 +88,8 @@ class LibraryRepository( fun starredSongs(sourceId: Long): Flow> = pager(60) { db.libraryDao().starredSongs(sourceId) } - suspend fun starredSongIds(sourceId: Long): List = db.libraryDao().starredSongIds(sourceId) - fun downloadedSongs(sourceId: Long): Flow> = db.libraryDao().downloadedSongs(sourceId) - suspend fun downloadedSongIds(sourceId: Long): List = db.libraryDao().downloadedSongIds(sourceId) - fun downloadedSongsPage(sourceId: Long): Flow> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) } fun downloadedAlbumsPage(sourceId: Long): Flow> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) } @@ -105,11 +101,6 @@ class LibraryRepository( genre: String, ): Flow> = pager(60) { db.libraryDao().songsByGenre(sourceId, genre) } - suspend fun genreSongIds( - sourceId: Long, - genre: String, - ): List = db.libraryDao().songsByGenreIds(sourceId, genre) - suspend fun genreSongOrdinal( sourceId: Long, genre: String, @@ -122,6 +113,11 @@ class LibraryRepository( id: String, ): Long = db.libraryDao().downloadedSongOrdinal(sourceId, id) + suspend fun starredSongOrdinal( + sourceId: Long, + id: String, + ): Long = db.libraryDao().starredSongOrdinal(sourceId, id) + fun albumsByDecade( sourceId: Long, decade: Long, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index 21cdf985..92b70794 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -262,6 +262,8 @@ class QueueRepository( fun downloadedEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Downloaded, refId = "") + fun starredEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Starred, refId = "") + suspend fun sourceName( kind: QueueKind, sourceId: Long, @@ -310,6 +312,10 @@ class QueueRepository( QueueKind.Downloaded -> { null } + + QueueKind.Starred -> { + null + } } } @@ -663,6 +669,7 @@ class QueueRepository( QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId) QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId) QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId) + QueueKind.Starred -> dao.starredSongIds(ref.sourceId) } val from = ref.offset.toInt().coerceIn(0, entryIds.size) val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size) @@ -765,6 +772,7 @@ class QueueRepository( QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit) QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit) QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit) + QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit) QueueKind.Song -> emptyList() } if (fresh.isNotEmpty()) remember(ref, offset, fresh) @@ -790,6 +798,7 @@ class QueueRepository( QueueKind.Artist -> OrderKey(row.song.id) QueueKind.Genre -> OrderKey(row.song.id) QueueKind.Downloaded -> OrderKey(row.song.id) + QueueKind.Starred -> OrderKey(row.song.id) QueueKind.Song -> OrderKey(row.song.id) } @@ -832,6 +841,7 @@ class QueueRepository( QueueKind.Artist -> emptyList() QueueKind.Genre -> emptyList() QueueKind.Downloaded -> emptyList() + QueueKind.Starred -> emptyList() QueueKind.Song -> emptyList() } @@ -847,10 +857,10 @@ class QueueRepository( QueueKind.Artist -> emptyList() QueueKind.Genre -> emptyList() QueueKind.Downloaded -> emptyList() + QueueKind.Starred -> emptyList() QueueKind.Song -> emptyList() } - // Artist songs span albums, so there is no single keyset to seek on; artist pages use OFFSET. private fun OrderKey.seekable(kind: QueueKind): Boolean = when (kind) { QueueKind.Playlist -> position != null @@ -858,6 +868,7 @@ class QueueRepository( QueueKind.Artist -> false QueueKind.Genre -> false QueueKind.Downloaded -> false + QueueKind.Starred -> false QueueKind.Song -> false } @@ -909,6 +920,10 @@ class QueueRepository( QueueKind.Downloaded -> { dao.downloadedLength(sourceId) } + + QueueKind.Starred -> { + dao.starredLength(sourceId) + } } val available = (total - offset).coerceAtLeast(0) return count?.coerceAtMost(available) ?: available diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index 96716611..b7c73858 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -285,7 +285,7 @@ private val songColumns = private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") -private fun Song.genreNames(): List = genres.ifEmpty { listOfNotNull(genre?.trim()?.takeIf { it.isNotEmpty() }) } +private fun Song.genreNames(): List = genres.distinct().ifEmpty { listOfNotNull(genre?.trim()?.takeIf { it.isNotEmpty() }) } private val songGenreColumns = listOf("sourceId", "songId", "position", "genre") diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 6e463de1..3849e39a 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -210,16 +210,6 @@ class PlaybackController( songId: String, ) = playContext(queueRepository.songEntry(sourceId, songId), 0) - fun playSongs( - sourceId: Long, - songIds: List, - startIndex: Int, - ) { - if (songIds.isEmpty()) return - val entries = songIds.map { id -> QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = id) } - play(entries, startIndex.toLong().coerceIn(0L, songIds.lastIndex.toLong())) - } - fun playPlaylist( sourceId: Long, playlistId: String, @@ -237,6 +227,11 @@ class PlaybackController( startOrdinal: Long, ) = playContext(queueRepository.downloadedEntry(sourceId), startOrdinal) + fun playStarred( + sourceId: Long, + startOrdinal: Long, + ) = playContext(queueRepository.starredEntry(sourceId), startOrdinal) + fun playAlbumInOrder( sourceId: Long, albumId: String, @@ -1250,6 +1245,12 @@ class PlaybackController( loadWindow(position, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) return } + // A track downloaded while it was streaming still holds a stream URL; fix the current item's + // source in place (without resetting the window) so an offline switch can play the local file. + val current = ordered[index] + val wantsLocal = downloads.localUri(current.item.song.id) != null + val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true + if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem()) windowPositions = LongArray(ordered.size) { ordered[it].position } windowFiltered = offline windowStart = ordered.first().position diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt index ddfe7bce..604df50f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -206,8 +206,8 @@ internal fun CoroutineScope.playStarredList( song: Song, ) { launch { - val ids = libraryRepository.starredSongIds(song.sourceId) - playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0)) + val ordinal = libraryRepository.starredSongOrdinal(song.sourceId, song.id) + playbackController.playStarred(song.sourceId, ordinal) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index a68257af..6e07c316 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -406,7 +406,11 @@ fun LibraryScreen( if (pagerState.currentPage != selectedTab.ordinal) { pagerState.animateScrollToPage(selectedTab.ordinal) } - if (search.isNotEmpty()) searchActive = true + if (selectedTab == LibraryTab.Home) { + searchActive = false + } else if (search.isNotEmpty()) { + searchActive = true + } } fun dismissSearch() { 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 c72c36f3..c9f0967f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -472,6 +472,7 @@ private fun QueueKind.label(): String = QueueKind.Artist -> "artist" QueueKind.Genre -> "genre" QueueKind.Downloaded -> "downloads" + QueueKind.Starred -> "starred" } private fun formatTime(milliseconds: Long): String { diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt index bda2b25c..9ca9e555 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt @@ -217,6 +217,27 @@ class LibraryHomeDaoTest { assertEquals(page.map { it.song.id }, dao.songsByGenreIds(sourceId, "Rock")) } + @Test + fun genreSongOrdinalMatchesTheListOrder() = + runTest { + val sourceId = source() + val dao = db.libraryDao() + dao.upsertSongs( + listOf( + song(sourceId, "s1", "Alpha"), + song(sourceId, "s2", "Bravo"), + song(sourceId, "s3", "Charlie"), + ), + ) + insertGenre(sourceId, "s1", 0, "Rock") + insertGenre(sourceId, "s2", 0, "Rock") + insertGenre(sourceId, "s3", 0, "Rock") + + assertEquals(0L, dao.genreSongOrdinal(sourceId, "Rock", "Alpha", "s1")) + assertEquals(1L, dao.genreSongOrdinal(sourceId, "Rock", "Bravo", "s2")) + assertEquals(2L, dao.genreSongOrdinal(sourceId, "Rock", "Charlie", "s3")) + } + @Test fun downloadedSongsOrderByNewestDownload() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt index 6778daa0..e261154a 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/MigrationsTest.kt @@ -134,6 +134,25 @@ class MigrationsTest { } } + @Test + fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() = + runTest { + driver.open(":memory:").use { migrated -> + applySchema(migrated, versions.first()) + MIGRATIONS.filter { it.endVersion <= 23 }.sortedBy { it.startVersion }.forEach { it.migrate(migrated) } + migrated.execSQL( + "INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)", + ) + migrated.execSQL("INSERT INTO songs (sourceId, id, title, genre) VALUES (1, 's1', 'Song', ' Rock ')") + migrated.execSQL("INSERT INTO songs (sourceId, id, title, genre) VALUES (1, 's2', 'Blank', ' ')") + + MIGRATIONS.first { it.endVersion == 24 }.migrate(migrated) + + assertEquals("Rock", text(migrated, "SELECT genre FROM song_genres WHERE songId = 's1'")) + assertEquals(0L, long(migrated, "SELECT COUNT(*) FROM song_genres WHERE songId = 's2'")) + } + } + private fun text( connection: SQLiteConnection, sql: String, diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 54a3d6d5..c20b4aaa 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -87,6 +87,24 @@ class QueueRepositoryTest { assertEquals(listOf("s1", "s3"), resolveAll(snapshot)) } + @Test + fun starredEntryResolvesStarredTracksNewestFirst() = + runTest { + seedLibrary() + db.libraryDao().upsertSongs( + listOf( + song("s1", "al1", track = 1, starred = 100), + song("s2", "al1", track = 2, starred = 300), + song("s3", "al1", track = 3, starred = 200), + ), + ) + + val snapshot = repository.snapshotAfter(listOf(repository.starredEntry(1))) + + assertEquals(3, snapshot.size) + assertEquals(listOf("s2", "s3", "s1"), resolveAll(snapshot)) + } + @Test fun rangeLimitsAnEntryToASlice() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 265b0462..2dea4a4d 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -326,7 +326,7 @@ class SyncServiceTest { } @Test - fun albumGenresUnionsSongGenresAndFallsBackToTheSingleValue() = + fun syncNormalizesSingularGenresIntoTheGenreRows() = runTest { insertSource() val source = @@ -342,7 +342,24 @@ class SyncServiceTest { SyncService(db, source).sync() - assertEquals(listOf("Jazz", "Pop", "Rock"), db.libraryDao().albumGenres(1, "al1").first()) + assertEquals(listOf("Rock", "Pop"), db.libraryDao().songGenres(1, "s1").first()) + assertEquals(listOf("Jazz"), db.libraryDao().songGenres(1, "s2").first()) + assertEquals(listOf("Rock"), db.libraryDao().songGenres(1, "s3").first()) + } + + @Test + fun thePluralGenresWinOverTheSingularAttribute() = + runTest { + insertSource() + val source = + FakeMusicSource( + albums = listOf(album("al1")), + songs = listOf(song("s1").copy(genres = listOf("Rock", "Pop"), genre = "Jazz")), + ) + + SyncService(db, source).sync() + + assertEquals(listOf("Rock", "Pop"), db.libraryDao().songGenres(1, "s1").first()) } @Test diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 394a7d0c..9f3decdf 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -2537,6 +2537,7 @@ class PlaybackControllerTest { await { handle.operations.any { it.startsWith("add") } } assertFalse(handle.operations.any { it.startsWith("setWindow") }) + assertFalse(handle.operations.any { it == "prepare" || it == "play" || it == "pause" }) assertEquals( "s5", controller.state.value.item @@ -2804,6 +2805,7 @@ class PlaybackControllerTest { await { handle.operations.any { it.startsWith("remove") || it.startsWith("add") } } assertFalse(handle.operations.any { it.startsWith("setWindow") }) + assertFalse(handle.operations.any { it == "prepare" || it == "play" || it == "pause" }) assertEquals( "s2", controller.state.value.item