ui: address review feedback on search focus and artist art

Keep the search field from stealing focus again when a result is dismissed, observe album and credit tables in artist paging, and simplify the cover fallback to one ranked union. Cover the association-table and invalidation paths in tests.
This commit is contained in:
austinried
2026-10-19 10:03:28 +00:00
parent 88e83c2a86
commit 915225f812
7 changed files with 138 additions and 36 deletions
@@ -10,6 +10,7 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Disc
@@ -18,6 +19,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow
@@ -163,34 +165,28 @@ 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_ALBUM_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums INDEXED BY index_albums_sourceId_artistId " +
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 " +
"INDEXED BY index_albums_sourceId_artistId " +
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"UNION " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
"SELECT 0, 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" +
") 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 " +
"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 " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
"UNION " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
"SELECT 1, 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" +
") 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)"
") credit_albums " +
"ORDER BY credit_albums.creditRank, credit_albums.year DESC, credit_albums.sortName COLLATE NOCASE, " +
"credit_albums.id LIMIT 1))"
internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
@@ -640,7 +636,16 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class, Source::class])
@RawQuery(
observedEntities = [
Artist::class,
Source::class,
Album::class,
AlbumArtistRef::class,
Song::class,
SongArtistRef::class,
],
)
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@RawQuery(observedEntities = [Playlist::class])
@@ -20,7 +20,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
@@ -28,7 +27,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import kotlinx.coroutines.launch
const val ARTIST_CHOOSER_TAG = "artistChooser"
@@ -40,7 +38,6 @@ fun ArtistChooserSheet(
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -66,11 +63,7 @@ fun ArtistChooserSheet(
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
onArtistClick(artist)
scope.launch { sheetState.hide() }
},
modifier = Modifier.clickable { onArtistClick(artist) },
)
}
}
@@ -23,6 +23,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -55,7 +56,13 @@ fun SearchField(
textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
}
}
LaunchedEffect(Unit) { focusRequester.requestFocus() }
var focusRequested by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(focusRequested) {
if (!focusRequested) {
focusRequester.requestFocus()
focusRequested = true
}
}
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -92,6 +92,7 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
@@ -446,6 +447,7 @@ fun LibraryScreen(
}
val density = LocalDensity.current
val focusManager = LocalFocusManager.current
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
@@ -495,7 +497,10 @@ fun LibraryScreen(
albums.collectAsLazyPagingItems(),
coverArt,
listBottomInset,
onAlbumClick,
{
focusManager.clearFocus()
onAlbumClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
@@ -512,7 +517,10 @@ fun LibraryScreen(
artists.collectAsLazyPagingItems(),
coverArt,
listBottomInset,
onArtistClick,
{
focusManager.clearFocus()
onArtistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
@@ -529,7 +537,10 @@ fun LibraryScreen(
playlists.collectAsLazyPagingItems(),
coverArt,
listBottomInset,
onPlaylistClick,
{
focusManager.clearFocus()
onPlaylistClick(it)
},
onLongClick = onItemLongClick,
filtered = filtersActive,
onClearFilters = onClearFilters,
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -85,6 +86,7 @@ fun SearchRoute(
val query by viewModel.query.collectAsStateWithLifecycle()
val results by viewModel.results.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
val actions =
ItemActions(
@@ -106,10 +108,22 @@ fun SearchRoute(
coverArt = viewModel::coverArt,
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::play,
onAlbumClick = {
focusManager.clearFocus()
onAlbumClick(it)
},
onArtistClick = {
focusManager.clearFocus()
onArtistClick(it)
},
onPlaylistClick = {
focusManager.clearFocus()
onPlaylistClick(it)
},
onSongClick = {
focusManager.clearFocus()
viewModel.play(it)
},
onLongClick = { contextMenuHost?.show(it, actions) },
)
}