mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
search: expose cross-library search from a home FAB
Add FTS5-backed search across songs, albums, artists and playlists, reachable from a search FAB on the Home tab. Terms of three characters or more run through the trigram indices; shorter terms fall back to the instr scan, matching the per-tab filters. Results are grouped by type and route into detail screens or song playback.
This commit is contained in:
@@ -128,6 +128,34 @@ internal const val PLAYLIST_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE
|
|||||||
internal const val PLAYLIST_ORDER_BY_UPDATED = "changed DESC, name COLLATE NOCASE, id"
|
internal const val PLAYLIST_ORDER_BY_UPDATED = "changed DESC, name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_UPDATED_REVERSED = "changed ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val PLAYLIST_ORDER_BY_UPDATED_REVERSED = "changed ASC, name COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
|
// Cross-type search: a three-character-or-longer query runs through the trigram index and anything
|
||||||
|
// shorter falls back to the instr scan, mirroring the per-tab filters above.
|
||||||
|
internal const val ALBUM_SEARCH_FILTER =
|
||||||
|
"(:search <> '' AND (" +
|
||||||
|
"(length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
|
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
|
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(albumArtist), lower(:search)) > 0))))"
|
||||||
|
|
||||||
|
internal const val ARTIST_SEARCH_FILTER =
|
||||||
|
"(:search <> '' AND (" +
|
||||||
|
"(length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
|
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
|
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)))"
|
||||||
|
|
||||||
|
internal const val PLAYLIST_SEARCH_FILTER =
|
||||||
|
"(:search <> '' AND (" +
|
||||||
|
"(length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
||||||
|
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
|
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)))"
|
||||||
|
|
||||||
|
internal const val SONG_SEARCH_FILTER =
|
||||||
|
"(:search <> '' AND (" +
|
||||||
|
"(length(:search) >= 3 AND songs.rowid IN (SELECT rowid FROM song_search " +
|
||||||
|
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
|
"OR (length(:search) < 3 AND (instr(lower(songs.title), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(songs.artist), lower(:search)) > 0))))"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -306,6 +334,48 @@ interface LibraryDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): Flow<List<AlbumSongItem>>
|
): Flow<List<AlbumSongItem>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||||
|
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
||||||
|
)
|
||||||
|
fun searchAlbums(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String,
|
||||||
|
limit: Int,
|
||||||
|
): Flow<List<Album>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
|
||||||
|
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
||||||
|
)
|
||||||
|
fun searchArtists(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String,
|
||||||
|
limit: Int,
|
||||||
|
): Flow<List<Artist>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT * FROM playlists WHERE sourceId = :sourceId AND $PLAYLIST_SEARCH_FILTER " +
|
||||||
|
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
||||||
|
)
|
||||||
|
fun searchPlaylists(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String,
|
||||||
|
limit: Int,
|
||||||
|
): Flow<List<Playlist>>
|
||||||
|
|
||||||
|
@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 $SONG_SEARCH_FILTER " +
|
||||||
|
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
||||||
|
)
|
||||||
|
fun searchSongs(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String,
|
||||||
|
limit: Int,
|
||||||
|
): Flow<List<AlbumSongItem>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT song_genres.genre FROM song_genres " +
|
"SELECT song_genres.genre FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ class LibraryRepository(
|
|||||||
|
|
||||||
fun downloadedSongsPage(sourceId: Long): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) }
|
fun downloadedSongsPage(sourceId: Long): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) }
|
||||||
|
|
||||||
|
fun searchAlbums(
|
||||||
|
sourceId: Long,
|
||||||
|
query: String,
|
||||||
|
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||||
|
|
||||||
|
fun searchArtists(
|
||||||
|
sourceId: Long,
|
||||||
|
query: String,
|
||||||
|
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||||
|
|
||||||
|
fun searchPlaylists(
|
||||||
|
sourceId: Long,
|
||||||
|
query: String,
|
||||||
|
): Flow<List<Playlist>> = db.libraryDao().searchPlaylists(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||||
|
|
||||||
|
fun searchSongs(
|
||||||
|
sourceId: Long,
|
||||||
|
query: String,
|
||||||
|
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||||
|
|
||||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
fun downloadedArtistsPage(sourceId: Long): Flow<PagingData<Artist>> = pager(60) { db.libraryDao().downloadedArtists(sourceId) }
|
fun downloadedArtistsPage(sourceId: Long): Flow<PagingData<Artist>> = pager(60) { db.libraryDao().downloadedArtists(sourceId) }
|
||||||
@@ -488,4 +508,6 @@ class LibraryRepository(
|
|||||||
|
|
||||||
const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const val SEARCH_RESULT_LIMIT = 50
|
||||||
|
|
||||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import com.subtracks.ui.library.ArtistDetailViewModel
|
|||||||
import com.subtracks.ui.library.LibraryViewModel
|
import com.subtracks.ui.library.LibraryViewModel
|
||||||
import com.subtracks.ui.library.PlaylistDetailViewModel
|
import com.subtracks.ui.library.PlaylistDetailViewModel
|
||||||
import com.subtracks.ui.playback.QueueViewModel
|
import com.subtracks.ui.playback.QueueViewModel
|
||||||
|
import com.subtracks.ui.search.SearchViewModel
|
||||||
import com.subtracks.ui.settings.AddSourceViewModel
|
import com.subtracks.ui.settings.AddSourceViewModel
|
||||||
import com.subtracks.ui.settings.SettingsViewModel
|
import com.subtracks.ui.settings.SettingsViewModel
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -116,6 +117,7 @@ fun appModule(
|
|||||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
|
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { HomeViewModel(get(), get(), get(), get()) }
|
viewModel { HomeViewModel(get(), get(), get(), get()) }
|
||||||
viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) }
|
viewModel { params -> HomeListViewModel(get(), get(), get(), params.get()) }
|
||||||
|
viewModel { SearchViewModel(get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get()) }
|
viewModel { SettingsViewModel(get(), get()) }
|
||||||
viewModel {
|
viewModel {
|
||||||
DownloadsViewModel(
|
DownloadsViewModel(
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import com.subtracks.ui.library.PlaylistDetailRoute
|
|||||||
import com.subtracks.ui.playback.MiniPlayer
|
import com.subtracks.ui.playback.MiniPlayer
|
||||||
import com.subtracks.ui.playback.NowPlayingRoute
|
import com.subtracks.ui.playback.NowPlayingRoute
|
||||||
import com.subtracks.ui.playback.QueueRoute
|
import com.subtracks.ui.playback.QueueRoute
|
||||||
|
import com.subtracks.ui.search.SearchRoute
|
||||||
import com.subtracks.ui.settings.AddSourceRoute
|
import com.subtracks.ui.settings.AddSourceRoute
|
||||||
import com.subtracks.ui.settings.LicensesRoute
|
import com.subtracks.ui.settings.LicensesRoute
|
||||||
import com.subtracks.ui.settings.SettingsRoute
|
import com.subtracks.ui.settings.SettingsRoute
|
||||||
@@ -117,6 +118,7 @@ private object Routes {
|
|||||||
const val LIBRARY = "library"
|
const val LIBRARY = "library"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val DOWNLOADS = "downloads"
|
const val DOWNLOADS = "downloads"
|
||||||
|
const val SEARCH = "search"
|
||||||
const val LICENSES = "licenses"
|
const val LICENSES = "licenses"
|
||||||
const val ADD_SERVER = "add-server"
|
const val ADD_SERVER = "add-server"
|
||||||
const val EDIT_SERVER = "edit-server/{sourceId}"
|
const val EDIT_SERVER = "edit-server/{sourceId}"
|
||||||
@@ -374,6 +376,7 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
},
|
},
|
||||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
onOpenSearch = { navController.navigate(Routes.SEARCH) },
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
||||||
@@ -399,6 +402,26 @@ private fun MainNavigation() {
|
|||||||
composable(Routes.DOWNLOADS) {
|
composable(Routes.DOWNLOADS) {
|
||||||
DownloadsRoute(onBack = { navController.popBackStack() })
|
DownloadsRoute(onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
composable(Routes.SEARCH) {
|
||||||
|
SearchRoute(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onAlbumClick = { album ->
|
||||||
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
|
},
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||||
|
},
|
||||||
|
onPlaylistClick = { playlist ->
|
||||||
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
|
},
|
||||||
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
|
onViewArtist = { artistId ->
|
||||||
|
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
|
||||||
|
},
|
||||||
|
contextMenuHost = contextMenuHost,
|
||||||
|
setStar = libraryRepository::star,
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(Routes.LICENSES) {
|
composable(Routes.LICENSES) {
|
||||||
LicensesRoute(onBack = { navController.popBackStack() })
|
LicensesRoute(onBack = { navController.popBackStack() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ private fun AlbumsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlbumListRow(
|
internal fun AlbumListRow(
|
||||||
album: Album,
|
album: Album,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
info: String?,
|
info: String?,
|
||||||
@@ -446,7 +446,7 @@ private fun ArtistsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistListRow(
|
internal fun ArtistListRow(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
info: String?,
|
info: String?,
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ fun LibraryRoute(
|
|||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit = {},
|
||||||
onViewAlbum: (String) -> Unit,
|
onViewAlbum: (String) -> Unit,
|
||||||
onViewArtist: (String) -> Unit,
|
onViewArtist: (String) -> Unit,
|
||||||
onHomeMore: (HomeSection) -> Unit = {},
|
onHomeMore: (HomeSection) -> Unit = {},
|
||||||
@@ -306,6 +307,7 @@ fun LibraryRoute(
|
|||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||||
listQuery = displayQuery,
|
listQuery = displayQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
@@ -383,6 +385,7 @@ fun LibraryScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit = {},
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
@@ -588,31 +591,36 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!searchActive && selectedTab != LibraryTab.Home) {
|
if (!searchActive) {
|
||||||
|
val searchFab = selectedTab == LibraryTab.Home
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { showOptions = true },
|
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(28.dp), contentAlignment = Alignment.Center) {
|
if (searchFab) {
|
||||||
ListOptionsGlyph(
|
Icon(Icons.Rounded.Search, contentDescription = stringResource(R.string.search))
|
||||||
modifier =
|
} else {
|
||||||
Modifier
|
Box(Modifier.size(28.dp), contentAlignment = Alignment.Center) {
|
||||||
.size(24.dp)
|
ListOptionsGlyph(
|
||||||
.semantics { contentDescription = listOptionsLabel },
|
|
||||||
)
|
|
||||||
if (filtersActive) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.size(24.dp)
|
||||||
.offset(x = 4.dp, y = (-4).dp)
|
.semantics { contentDescription = listOptionsLabel },
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
|
||||||
)
|
)
|
||||||
|
if (filtersActive) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = 4.dp, y = (-4).dp)
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,47 +83,16 @@ fun PlaylistsContent(
|
|||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
val playlist = items[index]
|
val playlist = items[index]
|
||||||
if (playlist != null) {
|
if (playlist != null) {
|
||||||
ListItem(
|
PlaylistRow(
|
||||||
headlineContent = {
|
playlist = playlist,
|
||||||
Text(
|
coverArt = coverArt,
|
||||||
text = playlist.name,
|
download = downloadStatuses[playlist.id],
|
||||||
maxLines = 1,
|
onClick = { onPlaylistClick(playlist) },
|
||||||
overflow = TextOverflow.Ellipsis,
|
onLongClick = {
|
||||||
|
onLongClick(
|
||||||
|
MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true), downloadStatuses[playlist.id]),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
playlist.comment?.takeIf { it.isNotBlank() }
|
|
||||||
?: pluralStringResource(
|
|
||||||
R.plurals.resources_song_count,
|
|
||||||
playlist.songCount.toInt(),
|
|
||||||
playlist.songCount,
|
|
||||||
),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
CoverArt(
|
|
||||||
ref = coverArt(playlist.coverArt, true),
|
|
||||||
name = playlist.name,
|
|
||||||
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
ListDownloadIndicator(downloadStatuses[playlist.id], Modifier.padding(end = 8.dp))
|
|
||||||
},
|
|
||||||
modifier =
|
|
||||||
Modifier.combinedClickable(
|
|
||||||
onClick = { onPlaylistClick(playlist) },
|
|
||||||
onLongClick = {
|
|
||||||
onLongClick(
|
|
||||||
MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true), downloadStatuses[playlist.id]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,3 +101,47 @@ fun PlaylistsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PlaylistRow(
|
||||||
|
playlist: Playlist,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
download: ListDownloadStatus?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = playlist.name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
playlist.comment?.takeIf { it.isNotBlank() }
|
||||||
|
?: pluralStringResource(
|
||||||
|
R.plurals.resources_song_count,
|
||||||
|
playlist.songCount.toInt(),
|
||||||
|
playlist.songCount,
|
||||||
|
),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
CoverArt(
|
||||||
|
ref = coverArt(playlist.coverArt, true),
|
||||||
|
name = playlist.name,
|
||||||
|
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
ListDownloadIndicator(download, Modifier.padding(end = 8.dp))
|
||||||
|
},
|
||||||
|
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package com.subtracks.ui.search
|
||||||
|
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
|
import com.subtracks.ui.components.EmptyState
|
||||||
|
import com.subtracks.ui.components.ItemActions
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.home.AlbumListRow
|
||||||
|
import com.subtracks.ui.home.ArtistListRow
|
||||||
|
import com.subtracks.ui.library.PlaylistRow
|
||||||
|
import com.subtracks.ui.library.SongRow
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchRoute(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onAlbumClick: (Album) -> Unit,
|
||||||
|
onArtistClick: (Artist) -> Unit,
|
||||||
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
onViewAlbum: (String) -> Unit,
|
||||||
|
onViewArtist: (String) -> Unit,
|
||||||
|
contextMenuHost: ContextMenuHost? = null,
|
||||||
|
setStar: (StarType, String, Boolean) -> Unit,
|
||||||
|
viewModel: SearchViewModel = koinViewModel(),
|
||||||
|
playbackController: PlaybackController = koinInject(),
|
||||||
|
) {
|
||||||
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
|
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||||
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
|
val actions =
|
||||||
|
ItemActions(
|
||||||
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
|
setStar = setStar,
|
||||||
|
viewAlbum = onViewAlbum,
|
||||||
|
viewArtist = onViewArtist,
|
||||||
|
)
|
||||||
|
SearchScreen(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = viewModel::setQuery,
|
||||||
|
results = results,
|
||||||
|
coverArt = viewModel::coverArt,
|
||||||
|
playingSongId = playback.item?.id,
|
||||||
|
onBack = onBack,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onSongClick = viewModel::play,
|
||||||
|
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
results: SearchResults,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
playingSongId: String? = null,
|
||||||
|
onBack: () -> Unit = {},
|
||||||
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
|
onArtistClick: (Artist) -> Unit = {},
|
||||||
|
onPlaylistClick: (Playlist) -> Unit = {},
|
||||||
|
onSongClick: (Song) -> Unit = {},
|
||||||
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = { Text("Search your library") },
|
||||||
|
trailingIcon = {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(Icons.Rounded.Close, contentDescription = "Clear search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors =
|
||||||
|
TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
when {
|
||||||
|
query.isBlank() -> {
|
||||||
|
EmptyState(
|
||||||
|
text = "Search for songs, albums, artists and playlists.",
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
results.isEmpty -> {
|
||||||
|
EmptyState(
|
||||||
|
text = "No results for \u201C$query\u201D.",
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
) {
|
||||||
|
songResults(results.songs, coverArt, playingSongId, onSongClick, onLongClick)
|
||||||
|
albumResults(results.albums, coverArt, onAlbumClick, onLongClick)
|
||||||
|
artistResults(results.artists, coverArt, onArtistClick, onLongClick)
|
||||||
|
playlistResults(results.playlists, coverArt, onPlaylistClick, onLongClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.resultHeader(title: String) {
|
||||||
|
item(key = "search-header-$title") {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.songResults(
|
||||||
|
songs: List<AlbumSongItem>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
playingSongId: String?,
|
||||||
|
onClick: (Song) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
) {
|
||||||
|
if (songs.isEmpty()) return
|
||||||
|
resultHeader("Songs")
|
||||||
|
items(songs, key = { it.song.id }) { item ->
|
||||||
|
SongRow(
|
||||||
|
song = item.song,
|
||||||
|
coverArtId = item.coverArt,
|
||||||
|
coverArt = coverArt,
|
||||||
|
isPlaying = item.song.id == playingSongId,
|
||||||
|
durationSeconds = item.song.duration,
|
||||||
|
modifier =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClick = { onClick(item.song) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.albumResults(
|
||||||
|
albums: List<Album>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
onClick: (Album) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
) {
|
||||||
|
if (albums.isEmpty()) return
|
||||||
|
resultHeader("Albums")
|
||||||
|
items(albums, key = { it.id }) { album ->
|
||||||
|
AlbumListRow(
|
||||||
|
album = album,
|
||||||
|
coverArt = coverArt,
|
||||||
|
info = null,
|
||||||
|
onClick = { onClick(album) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Album(album, coverArt(album.coverArt, true))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.artistResults(
|
||||||
|
artists: List<Artist>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
onClick: (Artist) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
) {
|
||||||
|
if (artists.isEmpty()) return
|
||||||
|
resultHeader("Artists")
|
||||||
|
items(artists, key = { it.id }) { artist ->
|
||||||
|
ArtistListRow(
|
||||||
|
artist = artist,
|
||||||
|
coverArt = coverArt,
|
||||||
|
info = null,
|
||||||
|
onClick = { onClick(artist) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.playlistResults(
|
||||||
|
playlists: List<Playlist>,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
onClick: (Playlist) -> Unit,
|
||||||
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
) {
|
||||||
|
if (playlists.isEmpty()) return
|
||||||
|
resultHeader("Playlists")
|
||||||
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
|
PlaylistRow(
|
||||||
|
playlist = playlist,
|
||||||
|
coverArt = coverArt,
|
||||||
|
download = null,
|
||||||
|
onClick = { onClick(playlist) },
|
||||||
|
onLongClick = { onLongClick(MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.subtracks.ui.search
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.home.playSongInContext
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
data class SearchResults(
|
||||||
|
val albums: List<Album> = emptyList(),
|
||||||
|
val artists: List<Artist> = emptyList(),
|
||||||
|
val playlists: List<Playlist> = emptyList(),
|
||||||
|
val songs: List<AlbumSongItem> = emptyList(),
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = albums.isEmpty() && artists.isEmpty() && playlists.isEmpty() && songs.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SearchViewModel(
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val sourceRepository: SourceRepository,
|
||||||
|
private val playbackController: PlaybackController,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _query = MutableStateFlow("")
|
||||||
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
|
val results: StateFlow<SearchResults> =
|
||||||
|
libraryRepository.activeSourceId
|
||||||
|
.flatMapLatest { sourceId ->
|
||||||
|
_query.flatMapLatest { text ->
|
||||||
|
if (sourceId == null || text.isBlank()) {
|
||||||
|
flowOf(SearchResults())
|
||||||
|
} else {
|
||||||
|
combine(
|
||||||
|
libraryRepository.searchAlbums(sourceId, text),
|
||||||
|
libraryRepository.searchArtists(sourceId, text),
|
||||||
|
libraryRepository.searchPlaylists(sourceId, text),
|
||||||
|
libraryRepository.searchSongs(sourceId, text),
|
||||||
|
) { albums, artists, playlists, songs ->
|
||||||
|
SearchResults(albums, artists, playlists, songs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchResults())
|
||||||
|
|
||||||
|
fun setQuery(value: String) {
|
||||||
|
_query.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun coverArt(
|
||||||
|
coverArt: String?,
|
||||||
|
thumbnail: Boolean,
|
||||||
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun play(song: Song) {
|
||||||
|
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@
|
|||||||
<string name="playlists_empty">No playlists yet.</string>
|
<string name="playlists_empty">No playlists yet.</string>
|
||||||
<string name="sync">Sync</string>
|
<string name="sync">Sync</string>
|
||||||
<string name="list_options">List options</string>
|
<string name="list_options">List options</string>
|
||||||
|
<string name="search">Search</string>
|
||||||
<string name="search_this_list">Search this list</string>
|
<string name="search_this_list">Search this list</string>
|
||||||
<string name="sort_by">Sort by</string>
|
<string name="sort_by">Sort by</string>
|
||||||
<string name="sort_ascending">Sort ascending</string>
|
<string name="sort_ascending">Sort ascending</string>
|
||||||
|
|||||||
@@ -703,6 +703,91 @@ class LibraryDaoTest {
|
|||||||
assertTrue(plan, plan.contains("album_search"))
|
assertTrue(plan, plan.contains("album_search"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crossTypeSearchMatchesAllFourEntityTypes() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Road Album", year = null, starred = null)))
|
||||||
|
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Road Artists", albumCount = 1)))
|
||||||
|
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Road Trip", created = 0, changed = 0)))
|
||||||
|
dao.upsertSongs(
|
||||||
|
listOf(
|
||||||
|
song(sourceId, "s1", "Road Song", starred = null),
|
||||||
|
song(sourceId, "s2", "Neutral", starred = null).copy(artist = "Roadie"),
|
||||||
|
song(sourceId, "s3", "Unrelated", starred = null).copy(artist = "Someone"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("Road Album"), dao.searchAlbums(sourceId, "oad", 50).first().map { it.name })
|
||||||
|
assertEquals(listOf("ar-1"), dao.searchArtists(sourceId, "oad", 50).first().map { it.id })
|
||||||
|
assertEquals(listOf("pl-1"), dao.searchPlaylists(sourceId, "oad", 50).first().map { it.id })
|
||||||
|
assertEquals(
|
||||||
|
setOf("s1", "s2"),
|
||||||
|
dao
|
||||||
|
.searchSongs(sourceId, "oad", 50)
|
||||||
|
.first()
|
||||||
|
.map { it.song.id }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(dao.searchAlbums(sourceId, "", 50).first().isEmpty())
|
||||||
|
assertTrue(dao.searchSongs(sourceId, "", 50).first().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crossTypeSearchIsScopedToTheSource() =
|
||||||
|
runTest {
|
||||||
|
val first = source()
|
||||||
|
val second = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertAlbums(
|
||||||
|
listOf(
|
||||||
|
album(first, "a1", "Road One", year = null, starred = null),
|
||||||
|
album(second, "a2", "Road Two", year = null, starred = null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("Road One"), dao.searchAlbums(first, "oad", 50).first().map { it.name })
|
||||||
|
assertEquals(listOf("Road Two"), dao.searchAlbums(second, "oad", 50).first().map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crossTypeSearchHonoursTheLimit() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertAlbums((1..3).map { album(sourceId, "a$it", "Road $it", year = null, starred = null) })
|
||||||
|
|
||||||
|
assertEquals(2, dao.searchAlbums(sourceId, "oad", 2).first().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songSearchRunsThroughTheTrigramIndex() =
|
||||||
|
runTest {
|
||||||
|
val plan =
|
||||||
|
plan(
|
||||||
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = 1 AND ${SONG_SEARCH_FILTER.replace(":search", "'abc'")} " +
|
||||||
|
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT 20 OFFSET 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(plan, plan.contains("song_search"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crossTypeSearchFallsBackToAScanBelowThreeCharacters() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Gamma", year = null, starred = null)))
|
||||||
|
dao.upsertSongs(listOf(song(sourceId, "s1", "Gamma Ray", starred = null)))
|
||||||
|
|
||||||
|
assertEquals(listOf("Gamma"), dao.searchAlbums(sourceId, "ga", 50).first().map { it.name })
|
||||||
|
assertEquals(listOf("s1"), dao.searchSongs(sourceId, "ga", 50).first().map { it.song.id })
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun plan(sql: String): String =
|
private suspend fun plan(sql: String): String =
|
||||||
db.useReaderConnection { connection ->
|
db.useReaderConnection { connection ->
|
||||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||||
|
|||||||
@@ -110,6 +110,33 @@ class LibraryScreenTest {
|
|||||||
assertTrue("an empty library should offer a sync", synced)
|
assertTrue("an empty library should offer a sync", synced)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theHomeTabOffersSearch() {
|
||||||
|
var searched = false
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
LibraryScreen(
|
||||||
|
selectedTab = LibraryTab.Home,
|
||||||
|
onTabSelected = {},
|
||||||
|
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||||
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
|
coverArt = { _, _ -> null },
|
||||||
|
onAlbumClick = {},
|
||||||
|
onArtistClick = {},
|
||||||
|
onPlaylistClick = {},
|
||||||
|
onSync = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
onOpenSearch = { searched = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("Search").performClick()
|
||||||
|
|
||||||
|
assertTrue("the home tab should expose search", searched)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theVisibleTabAndItsNeighbourCollectTheirPagingFlows() {
|
fun theVisibleTabAndItsNeighbourCollectTheirPagingFlows() {
|
||||||
val collected = CopyOnWriteArrayList<String>()
|
val collected = CopyOnWriteArrayList<String>()
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.subtracks.ui.search
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||||
|
class SearchScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resultsAreGroupedByTypeAndTappingASongOrAlbumFiresItsCallback() {
|
||||||
|
var played: Song? = null
|
||||||
|
var opened: Album? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SearchScreen(
|
||||||
|
query = "road",
|
||||||
|
onQueryChange = {},
|
||||||
|
results =
|
||||||
|
SearchResults(
|
||||||
|
albums = listOf(album("al-1", "Road Album")),
|
||||||
|
artists = listOf(artist("ar-1", "Road Artist")),
|
||||||
|
playlists = listOf(playlist("pl-1", "Road Trip")),
|
||||||
|
songs = listOf(AlbumSongItem(song("s1", "Road Song"), coverArt = null)),
|
||||||
|
),
|
||||||
|
onSongClick = { played = it },
|
||||||
|
onAlbumClick = { opened = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Songs").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Albums").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Playlists").assertIsDisplayed()
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Road Song").performClick()
|
||||||
|
composeRule.onNodeWithText("Road Album").performClick()
|
||||||
|
|
||||||
|
assertEquals("s1", played?.id)
|
||||||
|
assertEquals("al-1", opened?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEmptyQueryPromptsAndAnEmptyResultSaysSo() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SearchScreen(query = "", onQueryChange = {}, results = SearchResults())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Search for songs, albums, artists and playlists.").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aQueryWithNoResultsSaysSo() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SearchScreen(query = "zzz", onQueryChange = {}, results = SearchResults())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun album(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) = Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = id,
|
||||||
|
artistId = "ar-1",
|
||||||
|
name = name,
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = null,
|
||||||
|
starred = null,
|
||||||
|
songCount = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun artist(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) = Artist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
albumCount = 1,
|
||||||
|
starred = null,
|
||||||
|
coverArt = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun playlist(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) = Playlist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
comment = null,
|
||||||
|
coverArt = null,
|
||||||
|
songCount = 1,
|
||||||
|
created = 0,
|
||||||
|
changed = 0,
|
||||||
|
duration = 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
) = Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = id,
|
||||||
|
albumId = "al-1",
|
||||||
|
artistId = "ar-1",
|
||||||
|
title = title,
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.subtracks.ui.search
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.cancelAndJoinBlocking
|
||||||
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.download.ArtworkStore
|
||||||
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.NetworkServerActionSink
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.playback.FakePlayerConnection
|
||||||
|
import com.subtracks.playback.FakePlayerHandle
|
||||||
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SearchViewModelTest {
|
||||||
|
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
|
||||||
|
private lateinit var db: SubtracksDatabase
|
||||||
|
private lateinit var sourceRepository: SourceRepository
|
||||||
|
private lateinit var viewModel: SearchViewModel
|
||||||
|
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
db =
|
||||||
|
Room
|
||||||
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
|
.setDriver(BundledSQLiteDriver())
|
||||||
|
.build()
|
||||||
|
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
|
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||||
|
val downloads =
|
||||||
|
DownloadRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
FakeDownloadEngine(),
|
||||||
|
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||||
|
artworkStore = artwork,
|
||||||
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
scope = repoScope,
|
||||||
|
)
|
||||||
|
val controller =
|
||||||
|
PlaybackController(
|
||||||
|
sourceRepository,
|
||||||
|
QueueRepository(db),
|
||||||
|
FakePlayerConnection(FakePlayerHandle()),
|
||||||
|
downloads,
|
||||||
|
scope = controllerScope,
|
||||||
|
)
|
||||||
|
viewModel =
|
||||||
|
SearchViewModel(
|
||||||
|
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||||
|
sourceRepository,
|
||||||
|
controller,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||||
|
db.close()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
dispatcher.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchFindsEveryEntityTypeAndBlankClearsTheResults() {
|
||||||
|
runBlocking {
|
||||||
|
val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||||
|
db.libraryDao().upsertAlbums(listOf(album(id, "al-1", "Road Album")))
|
||||||
|
db.libraryDao().upsertArtists(listOf(artist(id, "ar-1", "Road Artist")))
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist(id, "pl-1", "Road Trip")))
|
||||||
|
db.libraryDao().upsertSongs(listOf(song(id, "s1", "Road Song")))
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.setQuery("oad")
|
||||||
|
val results = runBlocking { withTimeout(5_000) { viewModel.results.first { !it.isEmpty } } }
|
||||||
|
|
||||||
|
assertEquals(listOf("Road Album"), results.albums.map { it.name })
|
||||||
|
assertEquals(listOf("ar-1"), results.artists.map { it.id })
|
||||||
|
assertEquals(listOf("pl-1"), results.playlists.map { it.id })
|
||||||
|
assertEquals(listOf("s1"), results.songs.map { it.song.id })
|
||||||
|
|
||||||
|
viewModel.setQuery("")
|
||||||
|
val blank = runBlocking { withTimeout(5_000) { viewModel.results.first { it.isEmpty } } }
|
||||||
|
|
||||||
|
assertTrue(blank.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun album(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) = Album(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = id,
|
||||||
|
artistId = "ar-1",
|
||||||
|
name = name,
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = null,
|
||||||
|
starred = null,
|
||||||
|
songCount = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun artist(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) = Artist(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
albumCount = 1,
|
||||||
|
starred = null,
|
||||||
|
coverArt = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun playlist(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) = Playlist(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
comment = null,
|
||||||
|
coverArt = null,
|
||||||
|
songCount = 1,
|
||||||
|
created = 0,
|
||||||
|
changed = 0,
|
||||||
|
duration = 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
) = Song(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = id,
|
||||||
|
albumId = "al-1",
|
||||||
|
artistId = "ar-1",
|
||||||
|
title = title,
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user