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 instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_COVER_ART =
"COALESCE(artists.coverArt, (" +
"SELECT credit_albums.coverArt FROM (" +
"SELECT 0 AS creditRank, albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
internal const val ARTIST_ALBUM_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
"INDEXED BY index_albums_sourceId_artistId " +
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"UNION " +
"SELECT 0, albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
"UNION ALL " +
"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 " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id " +
"UNION " +
"SELECT 1, albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
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 " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
"UNION " +
"SELECT 1, albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
"UNION ALL " +
"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 albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" +
") credit_albums " +
"ORDER BY credit_albums.creditRank, credit_albums.year DESC, credit_albums.sortName COLLATE NOCASE, " +
"credit_albums.id LIMIT 1))"
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, 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 =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
@@ -57,7 +57,7 @@ fun SearchField(
}
}
var focusRequested by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(focusRequested) {
LaunchedEffect(Unit) {
if (!focusRequested) {
focusRequester.requestFocus()
focusRequested = true
@@ -273,6 +273,7 @@ fun LibraryRoute(
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val itemActions =
@@ -312,7 +313,10 @@ fun LibraryRoute(
onSync = viewModel::sync,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
onItemLongClick = {
focusManager.clearFocus()
contextMenuHost?.show(it, itemActions)
},
listQuery = displayQuery,
resetKeys = resetKeys,
offline = offline,
@@ -124,7 +124,10 @@ fun SearchRoute(
focusManager.clearFocus()
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)))
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
@@ -46,7 +46,7 @@ class ArtistChooserSheetTest {
}
@Test
fun opensTheChosenArtistBeforeTheSheetFinishesClosing() {
fun opensTheChosenArtistImmediately() {
var opened: ArtistRef? = null
composeRule.setContent {
SubtracksTheme {
@@ -6,7 +6,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -91,7 +94,7 @@ class SearchScreenTest {
}
@Test
fun typingResumesAtTheEndOfTheQueryAfterTheScreenIsRecreated() {
fun typingKeepsTheCursorAtTheEndAfterTheFieldIsRecreated() {
var query by mutableStateOf("road")
var show by mutableStateOf(true)
composeRule.setContent {
@@ -120,6 +123,21 @@ class SearchScreenTest {
.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
fun idsSharedAcrossTypesDoNotCollide() {
composeRule.setContent {