mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
rework the library lists: inline filter, playlist updated, starred sort, clearer controls
This commit is contained in:
@@ -90,179 +90,354 @@ interface LibraryDao {
|
||||
)
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByName(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY name COLLATE NOCASE DESC",
|
||||
)
|
||||
fun albumsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByArtist(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC",
|
||||
)
|
||||
fun albumsByArtistReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY year DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByYear(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY year ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByYearReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY created DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByRecentlyAdded(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY created ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByRecentlyAddedReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByStarred(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%' OR albumArtist LIKE '%' || :search || '%') " +
|
||||
"ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun albumsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
)
|
||||
fun artistsByName(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY name COLLATE NOCASE DESC",
|
||||
)
|
||||
fun artistsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY albumCount DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun artistsByAlbumCount(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY albumCount ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun artistsByAlbumCountReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
|
||||
fun playlistsByName(sourceId: Long): PagingSource<Int, Playlist>
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun artistsByStarred(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE DESC")
|
||||
fun playlistsByNameReversed(sourceId: Long): PagingSource<Int, Playlist>
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredOnly = 0 OR starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun artistsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
|
||||
fun playlistsByRecentlyAdded(sourceId: Long): PagingSource<Int, Playlist>
|
||||
@Query(
|
||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY name COLLATE NOCASE",
|
||||
)
|
||||
fun playlistsByName(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY created ASC, name COLLATE NOCASE")
|
||||
fun playlistsByRecentlyAddedReversed(sourceId: Long): PagingSource<Int, Playlist>
|
||||
@Query(
|
||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY name COLLATE NOCASE DESC",
|
||||
)
|
||||
fun playlistsByNameReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY created DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun playlistsByAdded(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY created ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun playlistsByAddedReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY changed DESC, name COLLATE NOCASE",
|
||||
)
|
||||
fun playlistsByUpdated(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:search = '' OR name LIKE '%' || :search || '%') " +
|
||||
"ORDER BY changed ASC, name COLLATE NOCASE",
|
||||
)
|
||||
fun playlistsByUpdatedReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
): PagingSource<Int, 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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " +
|
||||
"songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songs(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByTitle(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.title COLLATE NOCASE DESC, songs.id",
|
||||
)
|
||||
fun songsByTitleReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.artist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
||||
)
|
||||
fun songsByArtist(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.artist COLLATE NOCASE DESC, songs.album COLLATE NOCASE, songs.disc, songs.track, songs.id",
|
||||
)
|
||||
fun songsByArtistReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.starred IS NULL, songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByStarred(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@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 (:starredOnly = 0 OR songs.starred IS NOT NULL) " +
|
||||
"AND (:search = '' OR songs.title LIKE '%' || :search || '%' " +
|
||||
"OR songs.artist LIKE '%' || :search || '%' OR songs.album LIKE '%' || :search || '%') " +
|
||||
"ORDER BY songs.starred IS NULL, songs.starred ASC, songs.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
fun songsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): PagingSource<Int, SongListItem>
|
||||
|
||||
@Query(PLAYLIST_SONGS_SQL)
|
||||
|
||||
@@ -72,4 +72,12 @@ val MIGRATION_6_7 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
|
||||
val MIGRATION_7_8 =
|
||||
object : Migration(7, 8) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `changed` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> =
|
||||
arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
|
||||
|
||||
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
|
||||
ShuffleOrder::class,
|
||||
ArtworkSeed::class,
|
||||
],
|
||||
version = 7,
|
||||
version = 8,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class)
|
||||
|
||||
@@ -110,6 +110,7 @@ data class Playlist(
|
||||
val coverArt: String?,
|
||||
val songCount: Long,
|
||||
val created: Long,
|
||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, RecentlyAdded }
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount }
|
||||
enum class ArtistSort { Name, AlbumCount, Starred }
|
||||
|
||||
enum class PlaylistSort { Name, RecentlyAdded }
|
||||
enum class PlaylistSort { Name, Added, Updated }
|
||||
|
||||
enum class SongSort { Album, Title, Artist }
|
||||
enum class SongSort { Album, Title, Artist, Starred }
|
||||
|
||||
enum class LibraryListTab(
|
||||
val key: String,
|
||||
|
||||
@@ -16,14 +16,6 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SongSort
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class SearchHit(
|
||||
val type: String,
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
) {
|
||||
val isSong: Boolean get() = type == "song"
|
||||
}
|
||||
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
@@ -35,27 +27,48 @@ class LibraryRepository(
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) dao.albumsByNameReversed(sourceId, starredOnly) else dao.albumsByName(sourceId, starredOnly)
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) dao.albumsByArtistReversed(sourceId, starredOnly) else dao.albumsByArtist(sourceId, starredOnly)
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) dao.albumsByYearReversed(sourceId, starredOnly) else dao.albumsByYear(sourceId, starredOnly)
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.RecentlyAdded -> {
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starredOnly)
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starredOnly)
|
||||
dao.albumsByRecentlyAdded(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,19 +79,32 @@ class LibraryRepository(
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): Flow<PagingData<Artist>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) dao.artistsByNameReversed(sourceId, starredOnly) else dao.artistsByName(sourceId, starredOnly)
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starredOnly)
|
||||
dao.artistsByAlbumCountReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starredOnly)
|
||||
dao.artistsByAlbumCount(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,16 +114,33 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
search: String,
|
||||
): Flow<PagingData<Playlist>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
when (sort) {
|
||||
PlaylistSort.Name -> {
|
||||
if (descending) dao.playlistsByNameReversed(sourceId) else dao.playlistsByName(sourceId)
|
||||
if (descending) {
|
||||
dao.playlistsByNameReversed(sourceId, search)
|
||||
} else {
|
||||
dao.playlistsByName(sourceId, search)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.RecentlyAdded -> {
|
||||
if (descending) dao.playlistsByRecentlyAddedReversed(sourceId) else dao.playlistsByRecentlyAdded(sourceId)
|
||||
PlaylistSort.Added -> {
|
||||
if (descending) {
|
||||
dao.playlistsByAddedReversed(sourceId, search)
|
||||
} else {
|
||||
dao.playlistsByAdded(sourceId, search)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Updated -> {
|
||||
if (descending) {
|
||||
dao.playlistsByUpdatedReversed(sourceId, search)
|
||||
} else {
|
||||
dao.playlistsByUpdated(sourceId, search)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,34 +150,41 @@ class LibraryRepository(
|
||||
sort: SongSort,
|
||||
descending: Boolean,
|
||||
starredOnly: Boolean,
|
||||
search: String,
|
||||
): Flow<PagingData<SongListItem>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
when (sort) {
|
||||
SongSort.Album -> {
|
||||
dao.songs(sourceId, starredOnly)
|
||||
dao.songs(sourceId, starredOnly, search)
|
||||
}
|
||||
|
||||
SongSort.Title -> {
|
||||
if (descending) dao.songsByTitleReversed(sourceId, starredOnly) else dao.songsByTitle(sourceId, starredOnly)
|
||||
if (descending) {
|
||||
dao.songsByTitleReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.songsByTitle(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
SongSort.Artist -> {
|
||||
if (descending) dao.songsByArtistReversed(sourceId, starredOnly) else dao.songsByArtist(sourceId, starredOnly)
|
||||
if (descending) {
|
||||
dao.songsByArtistReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.songsByArtist(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
|
||||
SongSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.songsByStarredReversed(sourceId, starredOnly, search)
|
||||
} else {
|
||||
dao.songsByStarred(sourceId, starredOnly, search)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
limit: Int = 50,
|
||||
): List<SearchHit> =
|
||||
db
|
||||
.searchDao()
|
||||
.search(sourceId.toString(), query, limit)
|
||||
.map { SearchHit(it.type, it.itemId, it.title) }
|
||||
|
||||
fun album(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
|
||||
@@ -94,6 +94,7 @@ object SubsonicXml {
|
||||
coverArt = element.attr("coverArt").ifEmpty { null },
|
||||
songCount = element.longAttr("songCount") ?: 0L,
|
||||
created = element.dateAttr("created") ?: 0L,
|
||||
changed = element.dateAttr("changed") ?: 0L,
|
||||
duration = element.longAttr("duration") ?: 0L,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ private val songColumns =
|
||||
"genre",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "duration")
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
|
||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||
|
||||
@@ -132,7 +132,7 @@ private fun Album.values() =
|
||||
|
||||
private fun Song.values() = listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, duration)
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
|
||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.subtracks.ui.library.AlbumDetailViewModel
|
||||
import com.subtracks.ui.library.ArtistDetailViewModel
|
||||
import com.subtracks.ui.library.LibraryViewModel
|
||||
import com.subtracks.ui.library.PlaylistDetailViewModel
|
||||
import com.subtracks.ui.library.SearchViewModel
|
||||
import com.subtracks.ui.playback.QueueViewModel
|
||||
import com.subtracks.ui.settings.AddSourceViewModel
|
||||
import com.subtracks.ui.settings.SettingsViewModel
|
||||
@@ -41,7 +40,6 @@ fun appModule(
|
||||
single { PlaybackController(get(), get(), get()) }
|
||||
viewModel { RootViewModel(get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { SearchViewModel(get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get(), get()) }
|
||||
viewModel { AddSourceViewModel(get(), get()) }
|
||||
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
||||
|
||||
@@ -192,11 +192,6 @@ class PlaybackController(
|
||||
startPosition: Long,
|
||||
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
|
||||
|
||||
fun playSong(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
) = play(listOf(queueRepository.songEntry(sourceId, songId)), 0)
|
||||
|
||||
fun shuffleAlbum(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
|
||||
@@ -50,7 +50,6 @@ import com.subtracks.ui.library.AlbumDetailRoute
|
||||
import com.subtracks.ui.library.ArtistDetailRoute
|
||||
import com.subtracks.ui.library.LibraryRoute
|
||||
import com.subtracks.ui.library.PlaylistDetailRoute
|
||||
import com.subtracks.ui.library.SearchRoute
|
||||
import com.subtracks.ui.playback.MiniPlayer
|
||||
import com.subtracks.ui.playback.NowPlayingRoute
|
||||
import com.subtracks.ui.playback.QueueRoute
|
||||
@@ -82,7 +81,6 @@ private const val FLING_VELOCITY = 1000f
|
||||
|
||||
private object Routes {
|
||||
const val LIBRARY = "library"
|
||||
const val SEARCH = "search"
|
||||
const val SETTINGS = "settings"
|
||||
const val ADD_SERVER = "add-server"
|
||||
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
|
||||
@@ -186,18 +184,9 @@ private fun MainNavigation() {
|
||||
onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id, artist.coverArt)) },
|
||||
onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) },
|
||||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onSearch = { navController.navigate(Routes.SEARCH) },
|
||||
bottomInset = if (playerVisible) 0.dp else navBarInset,
|
||||
)
|
||||
}
|
||||
composable(Routes.SEARCH) {
|
||||
SearchRoute(
|
||||
onBack = { navController.popBackStack() },
|
||||
onAlbumClick = { navController.navigate(Routes.album(it)) },
|
||||
onArtistClick = { navController.navigate(Routes.artist(it)) },
|
||||
onPlaylistClick = { navController.navigate(Routes.playlist(it)) },
|
||||
)
|
||||
}
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsRoute(
|
||||
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -38,6 +39,7 @@ fun AlbumsContent(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onAlbumClick: (Album) -> Unit,
|
||||
resetKey: Any? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -51,6 +53,7 @@ fun AlbumsContent(
|
||||
|
||||
else -> {
|
||||
val gridState = rememberLazyGridState()
|
||||
LaunchedEffect(resetKey) { gridState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(gridState)
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -36,6 +37,7 @@ fun ArtistsContent(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
resetKey: Any? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -49,6 +51,7 @@ fun ArtistsContent(
|
||||
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -26,13 +26,15 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.MusicNote
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Sort
|
||||
import androidx.compose.material.icons.rounded.SwapVert
|
||||
import androidx.compose.material.icons.rounded.Sync
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -47,6 +49,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -62,6 +66,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
@@ -115,7 +121,8 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
||||
SortOption(AlbumSort.Name.name, "Name"),
|
||||
SortOption(AlbumSort.Artist.name, "Artist"),
|
||||
SortOption(AlbumSort.Year.name, "Year"),
|
||||
SortOption(AlbumSort.RecentlyAdded.name, "Recently added"),
|
||||
SortOption(AlbumSort.Added.name, "Added"),
|
||||
SortOption(AlbumSort.Starred.name, "Starred"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,13 +130,15 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
||||
listOf(
|
||||
SortOption(ArtistSort.Name.name, "Name"),
|
||||
SortOption(ArtistSort.AlbumCount.name, "Albums"),
|
||||
SortOption(ArtistSort.Starred.name, "Starred"),
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Playlists -> {
|
||||
listOf(
|
||||
SortOption(PlaylistSort.Name.name, "Name"),
|
||||
SortOption(PlaylistSort.RecentlyAdded.name, "Recently added"),
|
||||
SortOption(PlaylistSort.Added.name, "Added"),
|
||||
SortOption(PlaylistSort.Updated.name, "Updated"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,6 +147,7 @@ fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
||||
SortOption(SongSort.Album.name, "Album"),
|
||||
SortOption(SongSort.Title.name, "Title"),
|
||||
SortOption(SongSort.Artist.name, "Artist"),
|
||||
SortOption(SongSort.Starred.name, "Starred"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,15 +159,15 @@ fun LibraryRoute(
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onSearch: () -> Unit,
|
||||
bottomInset: Dp,
|
||||
viewModel: LibraryViewModel = koinViewModel(),
|
||||
) {
|
||||
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
|
||||
val listQuery by viewModel.listQuery(selectedTab.listTab()).collectAsStateWithLifecycle()
|
||||
val listTab = selectedTab.listTab()
|
||||
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
|
||||
val search by viewModel.search(listTab).collectAsStateWithLifecycle()
|
||||
LibraryScreen(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
@@ -182,7 +192,8 @@ fun LibraryRoute(
|
||||
onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) },
|
||||
onToggleStarred = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = !listQuery.starredOnly)) },
|
||||
onClearFilters = { viewModel.setListQuery(listTab, listQuery.copy(starredOnly = false)) },
|
||||
onSearch = onSearch,
|
||||
search = search,
|
||||
onSearchChange = { viewModel.setSearch(listTab, it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,7 +223,8 @@ fun LibraryScreen(
|
||||
onToggleSortDirection: () -> Unit = {},
|
||||
onToggleStarred: () -> Unit = {},
|
||||
onClearFilters: () -> Unit = {},
|
||||
onSearch: () -> Unit = {},
|
||||
search: String = "",
|
||||
onSearchChange: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pagerState =
|
||||
@@ -221,6 +233,8 @@ fun LibraryScreen(
|
||||
pageCount = { LibraryTab.entries.size },
|
||||
)
|
||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
@@ -247,7 +261,8 @@ fun LibraryScreen(
|
||||
val titleFraction = scrollBehavior.state.collapsedFraction
|
||||
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val listBottomInset = bottomInset + FAB_CLEARANCE
|
||||
val listBottomInset = bottomInset + if (searchActive) 0.dp else FAB_CLEARANCE
|
||||
val resetKey = listOf(listQuery.sort, listQuery.descending, listQuery.starredOnly, search)
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -296,30 +311,65 @@ fun LibraryScreen(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
) { page ->
|
||||
when (LibraryTab.entries[page]) {
|
||||
LibraryTab.Albums -> AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick)
|
||||
LibraryTab.Artists -> ArtistsContent(artists, coverArt, listBottomInset, onArtistClick)
|
||||
LibraryTab.Songs -> SongsContent(songs, coverArt, listBottomInset, onSongClick, playingSongId)
|
||||
LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick)
|
||||
LibraryTab.Albums -> {
|
||||
AlbumsContent(albums, coverArt, listBottomInset, onAlbumClick, resetKey = resetKey)
|
||||
}
|
||||
|
||||
LibraryTab.Artists -> {
|
||||
ArtistsContent(artists, coverArt, listBottomInset, onArtistClick, resetKey = resetKey)
|
||||
}
|
||||
|
||||
LibraryTab.Songs -> {
|
||||
SongsContent(
|
||||
songs,
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
onSongClick,
|
||||
playingSongId,
|
||||
resetKey = resetKey,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryTab.Playlists -> {
|
||||
PlaylistsContent(playlists, coverArt, listBottomInset, onPlaylistClick, resetKey = resetKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchActive) {
|
||||
SearchField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
onClose = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
},
|
||||
focusRequester = searchFocus,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = { showOptions = true },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
) {
|
||||
Box {
|
||||
Icon(Icons.Rounded.Sort, contentDescription = "List options")
|
||||
if (listQuery.starredOnly) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
if (!searchActive) {
|
||||
FloatingActionButton(
|
||||
onClick = { showOptions = true },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
) {
|
||||
Box {
|
||||
Icon(Icons.Filled.Sort, contentDescription = "List options")
|
||||
if (listQuery.starredOnly) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,7 +387,7 @@ fun LibraryScreen(
|
||||
onClearFilters = onClearFilters,
|
||||
onSearch = {
|
||||
showOptions = false
|
||||
onSearch()
|
||||
searchActive = true
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -358,7 +408,7 @@ private fun ListOptionsSheet(
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Search your library") },
|
||||
headlineContent = { Text("Search this list") },
|
||||
leadingContent = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onSearch),
|
||||
@@ -375,7 +425,10 @@ private fun ListOptionsSheet(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = onToggleSortDirection) {
|
||||
Icon(Icons.Rounded.SwapVert, contentDescription = "Reverse sort order")
|
||||
Icon(
|
||||
imageVector = if (listQuery.descending) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
|
||||
contentDescription = if (listQuery.descending) "Sort descending" else "Sort ascending",
|
||||
)
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
@@ -405,7 +458,7 @@ private fun ListOptionsSheet(
|
||||
FilterChip(
|
||||
selected = listQuery.starredOnly,
|
||||
onClick = onToggleStarred,
|
||||
label = { Text("Starred only") },
|
||||
label = { Text("Starred") },
|
||||
)
|
||||
if (listQuery.starredOnly) {
|
||||
TextButton(onClick = onClearFilters) { Text("Clear") }
|
||||
@@ -417,6 +470,37 @@ private fun ListOptionsSheet(
|
||||
|
||||
private val FAB_CLEARANCE = 80.dp
|
||||
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
placeholder = { Text("Search") },
|
||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Close search")
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LibraryTabs(
|
||||
|
||||
@@ -24,8 +24,10 @@ import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -47,12 +49,17 @@ class LibraryViewModel(
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, ListQuery(tab.defaultSort))
|
||||
}
|
||||
|
||||
private val searches: Map<LibraryListTab, MutableStateFlow<String>> =
|
||||
LibraryListTab.entries.associateWith { MutableStateFlow("") }
|
||||
|
||||
val albums: Flow<PagingData<Album>> =
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
listQueries.getValue(LibraryListTab.Albums).flatMapLatest { query ->
|
||||
libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starredOnly)
|
||||
combine(listQueries.getValue(LibraryListTab.Albums), searches.getValue(LibraryListTab.Albums)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.albums(sourceId, query.albumSort(), query.descending, query.starredOnly, search)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
@@ -60,8 +67,10 @@ class LibraryViewModel(
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
listQueries.getValue(LibraryListTab.Artists).flatMapLatest { query ->
|
||||
libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly)
|
||||
combine(listQueries.getValue(LibraryListTab.Artists), searches.getValue(LibraryListTab.Artists)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.artists(sourceId, query.artistSort(), query.descending, query.starredOnly, search)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
@@ -69,8 +78,10 @@ class LibraryViewModel(
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
listQueries.getValue(LibraryListTab.Playlists).flatMapLatest { query ->
|
||||
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending)
|
||||
combine(listQueries.getValue(LibraryListTab.Playlists), searches.getValue(LibraryListTab.Playlists)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.playlists(sourceId, query.playlistSort(), query.descending, search)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
@@ -78,8 +89,10 @@ class LibraryViewModel(
|
||||
libraryRepository.activeSourceId
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
listQueries.getValue(LibraryListTab.Songs).flatMapLatest { query ->
|
||||
libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly)
|
||||
combine(listQueries.getValue(LibraryListTab.Songs), searches.getValue(LibraryListTab.Songs)) { query, search ->
|
||||
query to search
|
||||
}.flatMapLatest { (query, search) ->
|
||||
libraryRepository.songs(sourceId, query.songSort(), query.descending, query.starredOnly, search)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
@@ -102,6 +115,15 @@ class LibraryViewModel(
|
||||
viewModelScope.launch { userPreferences.setListQuery(tab, query) }
|
||||
}
|
||||
|
||||
fun search(tab: LibraryListTab): StateFlow<String> = searches.getValue(tab)
|
||||
|
||||
fun setSearch(
|
||||
tab: LibraryListTab,
|
||||
value: String,
|
||||
) {
|
||||
searches.getValue(tab).value = value
|
||||
}
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -35,6 +36,7 @@ fun PlaylistsContent(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
bottomInset: Dp,
|
||||
onPlaylistClick: (Playlist) -> Unit,
|
||||
resetKey: Any? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -48,6 +50,7 @@ fun PlaylistsContent(
|
||||
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.MusicNote
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.repo.SearchHit
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun SearchRoute(
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
viewModel: SearchViewModel = koinViewModel(),
|
||||
) {
|
||||
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
LaunchedEffect(query) { viewModel.onQueryChange(query) }
|
||||
SearchScreen(
|
||||
query = query,
|
||||
results = results,
|
||||
onQueryChange = { query = it },
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::playSong,
|
||||
fieldModifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
query: String,
|
||||
results: List<SearchHit>,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onSongClick: (String) -> Unit,
|
||||
fieldModifier: Modifier = Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
placeholder = { Text("Search your library") },
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier.weight(1f).then(fieldModifier),
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
section("Songs", results.filter { it.type == "song" }, onSongClick)
|
||||
section("Albums", results.filter { it.type == "album" }, onAlbumClick)
|
||||
section("Artists", results.filter { it.type == "artist" }, onArtistClick)
|
||||
section("Playlists", results.filter { it.type == "playlist" }, onPlaylistClick)
|
||||
if (query.trim().length >= 3 && results.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "No matches.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.section(
|
||||
title: String,
|
||||
hits: List<SearchHit>,
|
||||
onItemClick: (String) -> Unit,
|
||||
) {
|
||||
if (hits.isEmpty()) return
|
||||
item(key = "header:$title") {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
items(hits, key = { "${it.type}:${it.itemId}" }) { hit ->
|
||||
SearchRow(hit = hit, onClick = { onItemClick(hit.itemId) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchRow(
|
||||
hit: SearchHit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = hit.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = iconFor(hit.type),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
private fun iconFor(type: String): ImageVector =
|
||||
when (type) {
|
||||
"song" -> Icons.Rounded.MusicNote
|
||||
"album" -> Icons.Rounded.Album
|
||||
"artist" -> Icons.Rounded.Person
|
||||
"playlist" -> Icons.AutoMirrored.Rounded.PlaylistPlay
|
||||
else -> Icons.Rounded.Search
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SearchHit
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
class SearchViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
private val query = MutableStateFlow("")
|
||||
|
||||
val results: StateFlow<List<SearchHit>> =
|
||||
query
|
||||
.debounce(SEARCH_DEBOUNCE_MS)
|
||||
.flatMapLatest { text ->
|
||||
sourceRepository
|
||||
.activeSourceId()
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId -> flow { emit(libraryRepository.search(sourceId, text)) } }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun onQueryChange(value: String) {
|
||||
query.value = value
|
||||
}
|
||||
|
||||
fun playSong(songId: String) {
|
||||
viewModelScope.launch {
|
||||
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@launch
|
||||
playbackController.playSong(sourceId, songId)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SEARCH_DEBOUNCE_MS = 200L
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -47,6 +48,7 @@ fun SongsContent(
|
||||
bottomInset: Dp,
|
||||
onSongClick: (Int) -> Unit,
|
||||
playingSongId: String? = null,
|
||||
resetKey: Any? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
@@ -60,6 +62,7 @@ fun SongsContent(
|
||||
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(resetKey) { listState.scrollToItem(0) }
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -48,10 +48,19 @@ class LibraryDaoTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Alpha", "Beta", "Gamma"), dao.albumsByName(sourceId, starredOnly = false).page().map { it.name })
|
||||
assertEquals(listOf("Gamma", "Beta", "Alpha"), dao.albumsByNameReversed(sourceId, starredOnly = false).page().map { it.name })
|
||||
assertEquals(listOf("Alpha", "Beta", "Gamma"), dao.albumsByYear(sourceId, starredOnly = false).page().map { it.name })
|
||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true).page().map { it.name })
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma"),
|
||||
dao.albumsByName(sourceId, starredOnly = false, search = "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Gamma", "Beta", "Alpha"),
|
||||
dao.albumsByNameReversed(sourceId, starredOnly = false, search = "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma"),
|
||||
dao.albumsByYear(sourceId, starredOnly = false, search = "").page().map { it.name },
|
||||
)
|
||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredOnly = true, search = "").page().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -66,9 +75,39 @@ class LibraryDaoTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Apple", "Zebra"), dao.songsByTitle(sourceId, starredOnly = false).page().map { it.song.title })
|
||||
assertEquals(listOf("Zebra", "Apple"), dao.songsByTitleReversed(sourceId, starredOnly = false).page().map { it.song.title })
|
||||
assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true).page().map { it.song.title })
|
||||
assertEquals(
|
||||
listOf("Apple", "Zebra"),
|
||||
dao.songsByTitle(sourceId, starredOnly = false, search = "").page().map { it.song.title },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zebra", "Apple"),
|
||||
dao.songsByTitleReversed(sourceId, starredOnly = false, search = "").page().map { it.song.title },
|
||||
)
|
||||
assertEquals(listOf("Apple"), dao.songsByTitle(sourceId, starredOnly = true, search = "").page().map { it.song.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumAddedAndStarredSortsAndSearch() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a", "Beta", year = 2000, starred = null, created = 300),
|
||||
album(sourceId, "b", "Alpha", year = 2010, starred = 200, created = 100),
|
||||
album(sourceId, "c", "Gamma", year = 1990, starred = 100, created = 200),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Gamma", "Alpha"),
|
||||
dao.albumsByRecentlyAdded(sourceId, starredOnly = false, search = "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Gamma", "Beta"),
|
||||
dao.albumsByStarred(sourceId, starredOnly = false, search = "").page().map { it.name },
|
||||
)
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredOnly = false, search = "gam").page().map { it.name })
|
||||
}
|
||||
|
||||
private suspend fun source(): Long =
|
||||
@@ -87,13 +126,14 @@ class LibraryDaoTest {
|
||||
name: String,
|
||||
year: Long?,
|
||||
starred: Long?,
|
||||
created: Long = 0,
|
||||
) = Album(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
artistId = "ar-1",
|
||||
name = name,
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
created = created,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = year,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.subtracks.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UserPreferencesTest {
|
||||
@Test
|
||||
fun listQueryRoundTripsPerTab() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(ListQuery("Name"), prefs.listQuery(LibraryListTab.Albums).first())
|
||||
assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first())
|
||||
|
||||
prefs.setListQuery(LibraryListTab.Albums, ListQuery("Added", descending = true, starredOnly = true))
|
||||
|
||||
assertEquals(ListQuery("Added", descending = true, starredOnly = true), prefs.listQuery(LibraryListTab.Albums).first())
|
||||
assertEquals(ListQuery("Album"), prefs.listQuery(LibraryListTab.Songs).first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class SyncServiceTest {
|
||||
2,
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, false)
|
||||
.artistsByName(1, false, "")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
@@ -68,7 +68,7 @@ class SyncServiceTest {
|
||||
2,
|
||||
db
|
||||
.libraryDao()
|
||||
.songs(1, false)
|
||||
.songs(1, false, "")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
@@ -90,7 +90,7 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, false)
|
||||
.artistsByName(1, false, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -98,7 +98,7 @@ class SyncServiceTest {
|
||||
listOf("s1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.songs(1, false)
|
||||
.songs(1, false, "")
|
||||
.allRows()
|
||||
.map { it.song.id },
|
||||
)
|
||||
@@ -200,7 +200,7 @@ class SyncServiceTest {
|
||||
"Old",
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, false)
|
||||
.artistsByName(1, false, "")
|
||||
.allRows()
|
||||
.single()
|
||||
.name,
|
||||
@@ -213,7 +213,7 @@ class SyncServiceTest {
|
||||
"New",
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, false)
|
||||
.artistsByName(1, false, "")
|
||||
.allRows()
|
||||
.single()
|
||||
.name,
|
||||
|
||||
+1
-32
@@ -5,7 +5,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -19,11 +18,9 @@ import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.data.repo.SearchHit
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.LibraryScreen
|
||||
import com.subtracks.ui.library.LibraryTab
|
||||
import com.subtracks.ui.library.SearchScreen
|
||||
import com.subtracks.ui.library.sortOptionsFor
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -36,7 +33,7 @@ import org.robolectric.annotation.GraphicsMode
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
class LibraryListsScreenshotTest {
|
||||
class LibraryOptionsScreenshotTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@@ -73,34 +70,6 @@ class LibraryListsScreenshotTest {
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_options.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun search() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SearchScreen(
|
||||
query = "radio",
|
||||
results =
|
||||
listOf(
|
||||
SearchHit("song", "s-eiirp", "Everything In Its Right Place"),
|
||||
SearchHit("album", "al-kid-a", "Kid A"),
|
||||
SearchHit("artist", "ar-radiohead", "Radiohead"),
|
||||
SearchHit("playlist", "pl-late-night", "Late Night"),
|
||||
),
|
||||
onQueryChange = {},
|
||||
onBack = {},
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithText("Radiohead").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/search.png")
|
||||
}
|
||||
|
||||
private fun albums() =
|
||||
(1..7).map { index ->
|
||||
Album(
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.subtracks.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.LibraryScreen
|
||||
import com.subtracks.ui.library.LibraryTab
|
||||
import com.subtracks.ui.library.sortOptionsFor
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
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 LibrarySearchScreenshotTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun librarySearch() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Albums,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) }.collectAsLazyPagingItems(),
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
|
||||
songs = remember { flowOf(PagingData.empty<SongListItem>()) }.collectAsLazyPagingItems(),
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSongClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
listQuery = ListQuery("Name"),
|
||||
sortOptions = sortOptionsFor(LibraryTab.Albums),
|
||||
starredSupported = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithContentDescription("List options").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onAllNodesWithText("Search this list")[0].performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_search.png")
|
||||
}
|
||||
|
||||
private fun albums() =
|
||||
(1..7).map { index ->
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al-$index",
|
||||
artistId = "ar-1",
|
||||
name = "Album $index",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
frequentRank = null,
|
||||
recentRank = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user