ui: finish search focus and artist art review follow-ups

Avoid re-requesting search focus when the screen is restored, keep the cheaper album-credit-first art lookup, and test restoration plus association-table invalidation.
This commit is contained in:
austinried
2026-10-19 10:14:01 +00:00
parent 915225f812
commit bb71f4889d
7 changed files with 77 additions and 19 deletions
@@ -165,28 +165,35 @@ internal const val ARTISTS_FILTER =
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) " "OR instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_COVER_ART = internal const val ARTIST_ALBUM_CREDIT_ART =
"COALESCE(artists.coverArt, (" + "SELECT albums.coverArt FROM (" +
"SELECT credit_albums.coverArt FROM (" + "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
"SELECT 0 AS creditRank, albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
"INDEXED BY index_albums_sourceId_artistId " + "INDEXED BY index_albums_sourceId_artistId " +
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " + "WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"UNION " + "UNION ALL " +
"SELECT 0, albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " + "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + "JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id " + "WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
"UNION " + ") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
"SELECT 1, albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
internal const val ARTIST_TRACK_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " + "WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
"UNION " + "UNION ALL " +
"SELECT 1, albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " + "SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + "JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" + "WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" +
") credit_albums " + ") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
"ORDER BY credit_albums.creditRank, credit_albums.year DESC, credit_albums.sortName COLLATE NOCASE, " +
"credit_albums.id LIMIT 1))" internal const val ARTIST_COVER_ART =
"COALESCE(artists.coverArt, CASE " +
"WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM album_artists " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " +
"THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)"
internal const val ARTIST_COLUMNS = internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " + "artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
@@ -57,7 +57,7 @@ fun SearchField(
} }
} }
var focusRequested by rememberSaveable { mutableStateOf(false) } var focusRequested by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(focusRequested) { LaunchedEffect(Unit) {
if (!focusRequested) { if (!focusRequested) {
focusRequester.requestFocus() focusRequester.requestFocus()
focusRequested = true focusRequested = true
@@ -273,6 +273,7 @@ fun LibraryRoute(
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() } val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
val downloads = downloadsState?.value ?: emptyMap() val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) } var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null } DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val itemActions = val itemActions =
@@ -312,7 +313,10 @@ fun LibraryRoute(
onSync = viewModel::sync, onSync = viewModel::sync,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onItemLongClick = { contextMenuHost?.show(it, itemActions) }, onItemLongClick = {
focusManager.clearFocus()
contextMenuHost?.show(it, itemActions)
},
listQuery = displayQuery, listQuery = displayQuery,
resetKeys = resetKeys, resetKeys = resetKeys,
offline = offline, offline = offline,
@@ -124,7 +124,10 @@ fun SearchRoute(
focusManager.clearFocus() focusManager.clearFocus()
viewModel.play(it) viewModel.play(it)
}, },
onLongClick = { contextMenuHost?.show(it, actions) }, onLongClick = {
focusManager.clearFocus()
contextMenuHost?.show(it, actions)
},
) )
} }
@@ -677,6 +677,32 @@ class LibraryDaoTest {
dao.upsertSongs(listOf(song(sourceId, "s-1", "Song", starred = null))) dao.upsertSongs(listOf(song(sourceId, "s-1", "Song", starred = null)))
awaitUntil("artist paging observes song writes") { songInvalidated.get() } awaitUntil("artist paging observes song writes") { songInvalidated.get() }
val albumCreditInvalidated = AtomicBoolean(false)
val albumCreditPaging = dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
albumCreditPaging.registerInvalidatedCallback { albumCreditInvalidated.set(true) }
albumCreditPaging.page()
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES ($sourceId, 'al-1', 0, 'ar-1', 'Artist')",
) { it.step() }
}
awaitUntil("artist paging observes album artist writes") { albumCreditInvalidated.get() }
val songCreditInvalidated = AtomicBoolean(false)
val songCreditPaging = dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
songCreditPaging.registerInvalidatedCallback { songCreditInvalidated.set(true) }
songCreditPaging.page()
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES ($sourceId, 's-1', 0, 'ar-1', 'Artist')",
) { it.step() }
}
awaitUntil("artist paging observes song artist writes") { songCreditInvalidated.get() }
} }
@Test @Test
@@ -46,7 +46,7 @@ class ArtistChooserSheetTest {
} }
@Test @Test
fun opensTheChosenArtistBeforeTheSheetFinishesClosing() { fun opensTheChosenArtistImmediately() {
var opened: ArtistRef? = null var opened: ArtistRef? = null
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
@@ -6,7 +6,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@@ -91,7 +94,7 @@ class SearchScreenTest {
} }
@Test @Test
fun typingResumesAtTheEndOfTheQueryAfterTheScreenIsRecreated() { fun typingKeepsTheCursorAtTheEndAfterTheFieldIsRecreated() {
var query by mutableStateOf("road") var query by mutableStateOf("road")
var show by mutableStateOf(true) var show by mutableStateOf(true)
composeRule.setContent { composeRule.setContent {
@@ -120,6 +123,21 @@ class SearchScreenTest {
.hideSoftInputFromWindow(composeRule.activity.window.decorView.windowToken, 0) .hideSoftInputFromWindow(composeRule.activity.window.decorView.windowToken, 0)
} }
@Test
fun restoringTheScreenDoesNotStealFocus() {
val restorationTester = StateRestorationTester(composeRule)
restorationTester.setContent {
SubtracksTheme {
SearchScreen(query = "road", onQueryChange = {}, results = SearchResults())
}
}
composeRule.onNode(hasSetTextAction()).assertIsFocused()
restorationTester.emulateSavedInstanceStateRestore()
composeRule.onNode(hasSetTextAction()).assertIsNotFocused()
}
@Test @Test
fun idsSharedAcrossTypesDoNotCollide() { fun idsSharedAcrossTypesDoNotCollide() {
composeRule.setContent { composeRule.setContent {