mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
Compare commits
30
Commits
5de5b618d1
...
f9151843a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9151843a6 | ||
|
|
2f95a89682 | ||
|
|
319ff4b645 | ||
|
|
d8ac7fefc7 | ||
|
|
f46f43b6cd | ||
|
|
8052e1db76 | ||
|
|
edc8782302 | ||
|
|
3ffe38d988 | ||
|
|
b76328fc26 | ||
|
|
9feaf54cd2 | ||
|
|
72e1b61bff | ||
|
|
bb3abe39b6 | ||
|
|
be08e50314 | ||
|
|
b9a3ae5ed0 | ||
|
|
47787182d0 | ||
|
|
aca2bd4be2 | ||
|
|
677596c21b | ||
|
|
1dbfb027fe | ||
|
|
8765c5d37b | ||
|
|
2da0d61d4c | ||
|
|
2c6af4b126 | ||
|
|
61774a19ca | ||
|
|
0602f835dc | ||
|
|
45fc169c42 | ||
|
|
e487d2e0b1 | ||
|
|
6f1e1612e1 | ||
|
|
185aaabbc5 | ||
|
|
c1a01b9a47 | ||
|
|
fbd4c0c4fe | ||
|
|
c56a6d4f83 |
+1997
File diff suppressed because it is too large
Load Diff
+2009
File diff suppressed because it is too large
Load Diff
@@ -50,11 +50,6 @@ internal const val ALBUMS_FILTER =
|
||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
|
||||
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||
@@ -70,6 +65,16 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C
|
||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
private const val ALBUM_ARTIST_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
|
||||
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
|
||||
@@ -199,18 +204,52 @@ private const val ARTIST_COVER_CREDITS =
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
||||
|
||||
internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL"
|
||||
|
||||
private const val ARTIST_COVER_RANKS_TAIL =
|
||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||
"deduped AS (SELECT artistId, coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked), " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||
|
||||
private const val ARTIST_COVER_RANKS =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS, " +
|
||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId), " +
|
||||
@@ -235,6 +274,10 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||
|
||||
internal const val ARTIST_COVER_ARTS_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||
|
||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||
"WHERE artistId IN (:artistIds)), " +
|
||||
@@ -285,8 +328,6 @@ internal const val ARTISTS_SELECT =
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
||||
|
||||
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
@@ -296,6 +337,15 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s
|
||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
|
||||
private const val SONG_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||
|
||||
internal const val PLAYLISTS_FILTER =
|
||||
"FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
||||
@@ -315,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
|
||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||
") ELSE playlists.songCount END AS songCount, " +
|
||||
"playlists.created, playlists.changed, playlists.duration "
|
||||
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
|
||||
|
||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
@@ -413,6 +463,12 @@ interface LibraryDao {
|
||||
@Query(ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeArtistArt(sourceId: Long)
|
||||
|
||||
@Query(ARTIST_COVER_ARTS_SQL)
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
@@ -566,22 +622,24 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<Artist>>
|
||||
|
||||
@Query(
|
||||
@@ -598,12 +656,13 @@ interface LibraryDao {
|
||||
"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.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<AlbumSongItem>>
|
||||
|
||||
@Query(
|
||||
@@ -745,36 +804,40 @@ interface LibraryDao {
|
||||
@RawQuery(observedEntities = [Playlist::class])
|
||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
||||
fun albumsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||
fun albumsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
|
||||
fun albumsByArtist(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
|
||||
fun albumsByArtistReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||
@@ -857,20 +920,22 @@ interface LibraryDao {
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
|
||||
fun artistsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||
fun artistsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||
@@ -42,8 +43,9 @@ internal fun albumsDefaultQuery(
|
||||
search: String,
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery {
|
||||
val order = albumOrder(sort, descending)
|
||||
val order = albumOrder(sort, descending, sortNameMode)
|
||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
}
|
||||
@@ -88,8 +90,9 @@ internal fun artistsDefaultQuery(
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending)
|
||||
val order = artistOrder(sort, descending, sortNameMode)
|
||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
@@ -121,27 +124,82 @@ internal fun playlistsDefaultQuery(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||
}
|
||||
|
||||
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> {
|
||||
"albumArtistSort COLLATE NOCASE"
|
||||
}
|
||||
|
||||
SortNameMode.Name -> {
|
||||
"albumArtist COLLATE NOCASE"
|
||||
}
|
||||
|
||||
SortNameMode.NonLatin -> {
|
||||
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||
}
|
||||
|
||||
private fun albumOrder(
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||
AlbumSort.Name -> {
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
val artist = albumArtistSortKey(mode)
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||
}
|
||||
}
|
||||
|
||||
private fun artistOrder(
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
||||
val name = artistNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
|
||||
@@ -706,6 +706,30 @@ val MIGRATION_35_36 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_36_37 =
|
||||
object : Migration(36, 37) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_37_38 =
|
||||
object : Migration(37, 38) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||
val triggers = mutableListOf<String>()
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||
@@ -782,4 +806,6 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
MIGRATION_35_36,
|
||||
MIGRATION_36_37,
|
||||
MIGRATION_37_38,
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 36,
|
||||
version = 38,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
|
||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
internal fun fractionOf(
|
||||
bytes: Long,
|
||||
total: Long,
|
||||
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
|
||||
enum class DownloadList { Album, Playlist, Artist }
|
||||
|
||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||
@@ -99,7 +104,7 @@ data class DownloadedSong(
|
||||
val songSort: String = title,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
data class DownloadArtwork(
|
||||
|
||||
@@ -59,6 +59,7 @@ data class ArtistRef(
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(
|
||||
name = "index_artists_albumCount",
|
||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||
@@ -131,7 +132,9 @@ data class ArtistBio(
|
||||
Index("sourceId"),
|
||||
Index("sourceId", "artistId"),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(
|
||||
name = "index_albums_year",
|
||||
value = ["sourceId", "year", "sortName", "id"],
|
||||
@@ -251,6 +254,8 @@ data class Playlist(
|
||||
val created: Long,
|
||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||
val owner: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class SortNameMode { SortName, Name, NonLatin }
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||
@@ -182,6 +184,17 @@ class UserPreferences(
|
||||
|
||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||
|
||||
fun sortNameMode(): Flow<SortNameMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||
}
|
||||
|
||||
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||
|
||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
|
||||
@@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val userPreferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
@@ -111,12 +116,18 @@ class LibraryRepository(
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Album>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Artist>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchPlaylists(
|
||||
sourceId: Long,
|
||||
@@ -126,7 +137,10 @@ class LibraryRepository(
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<AlbumSongItem>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||
|
||||
@@ -178,66 +192,68 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,50 +268,52 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Artist>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +442,11 @@ class LibraryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||
|
||||
fun albumDiscs(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
|
||||
@@ -129,6 +129,8 @@ object SubsonicXml {
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
changed = attrs.dateAttr("changed") ?: 0L,
|
||||
duration = attrs.longAttr("duration") ?: 0L,
|
||||
owner = attrs.attr("owner").ifEmpty { null },
|
||||
shared = attrs.boolAttr("public"),
|
||||
)
|
||||
|
||||
internal fun playlistSong(
|
||||
@@ -365,6 +367,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||
|
||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||
|
||||
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
|
||||
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||
|
||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||
|
||||
@@ -487,7 +487,8 @@ private val songColumns =
|
||||
"artistSort",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
private val playlistColumns =
|
||||
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
|
||||
|
||||
private fun Song.genreNames(): List<String> =
|
||||
genres
|
||||
@@ -577,7 +578,7 @@ private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.pos
|
||||
|
||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
|
||||
|
||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ fun appModule(
|
||||
scope = get(),
|
||||
)
|
||||
}
|
||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||
single {
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
@@ -238,7 +241,14 @@ class Media3PlayerHandle(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||
mediaMetadata.extras
|
||||
?.getString(EXTRA_ARTISTS)
|
||||
?.let(ArtistRefsCodec::decode)
|
||||
.orEmpty()
|
||||
|
||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||
private const val EXTRA_ARTISTS = "artists"
|
||||
private const val EXTRA_DURATION_MS = "durationMs"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||
@@ -286,6 +296,7 @@ internal fun queueMediaItem(
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
@@ -36,6 +37,7 @@ data class QueueItem(
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
val albumContext: Boolean = false,
|
||||
val artists: List<ArtistRef> = emptyList(),
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.SongItem
|
||||
|
||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
@@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artists = song.artistRefs,
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
|
||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.AlbumInfoDialog
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.ArtistInfoDialog
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.ItemContextMenu
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||
import com.subtracks.ui.components.SongInfoDialog
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.downloads.DownloadsRoute
|
||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val contextMenuHost = remember { ContextMenuHost() }
|
||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||
|
||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
||||
nowPlayingLauncher.openNowPlaying.collect {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost.dismissTransients()
|
||||
infoSong = null
|
||||
infoTarget = null
|
||||
artistChooser = null
|
||||
showingQueue = false
|
||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||
@@ -773,23 +781,69 @@ private fun MainNavigation() {
|
||||
target = target,
|
||||
actions = contextMenuHost.actions,
|
||||
onDismiss = { contextMenuHost.dismiss() },
|
||||
onInfo = { infoSong = it },
|
||||
onInfo = { infoTarget = it },
|
||||
onChooseArtists = { artistChooser = it },
|
||||
)
|
||||
}
|
||||
|
||||
infoSong?.let { song ->
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoSong = null },
|
||||
genres = genres,
|
||||
artistChooser?.let { artists ->
|
||||
ArtistChooserSheet(
|
||||
artists = artists,
|
||||
onArtistClick = { artist ->
|
||||
artistChooser = null
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(artist.id),
|
||||
)
|
||||
},
|
||||
onDismiss = { artistChooser = null },
|
||||
)
|
||||
}
|
||||
|
||||
when (val target = infoTarget) {
|
||||
is MenuTarget.Song -> {
|
||||
val song = target.song
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoTarget = null },
|
||||
genres = genres,
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
AlbumInfoDialog(
|
||||
album = target.album,
|
||||
download = target.download,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
ArtistInfoDialog(
|
||||
artist = target.artist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
PlaylistInfoDialog(
|
||||
playlist = target.playlist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
|
||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentReady: Boolean = true,
|
||||
) {
|
||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(effectiveArtwork) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberRestorableLazyListState(contentReady)
|
||||
val scope = rememberCoroutineScope()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
|
||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||
|
||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
||||
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
val index = listState.firstVisibleItemIndex
|
||||
@@ -206,6 +213,31 @@ fun HeroDetailScaffold(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||
val state = rememberLazyListState()
|
||||
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||
LaunchedEffect(contentReady) {
|
||||
if (contentReady && pending) {
|
||||
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||
state.scrollToItem(target, offset)
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||
.collect { (currentIndex, currentOffset) ->
|
||||
if (!pending) {
|
||||
index = currentIndex
|
||||
offset = currentOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroSubtitle(
|
||||
text: String,
|
||||
@@ -291,7 +323,11 @@ fun HeroHeader(
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(min = 32.dp)
|
||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClickLabel = stringResource(R.string.open_artist),
|
||||
) { onSubtitleClick() },
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.Cancel
|
||||
@@ -50,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -197,7 +199,8 @@ fun ItemContextMenu(
|
||||
target: MenuTarget,
|
||||
actions: ItemActions,
|
||||
onDismiss: () -> Unit,
|
||||
onInfo: ((SongModel) -> Unit)? = null,
|
||||
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -210,6 +213,11 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceWith(after: () -> Unit) {
|
||||
onDismiss()
|
||||
after()
|
||||
}
|
||||
|
||||
val queueRef = target.queueRef()
|
||||
|
||||
@Composable
|
||||
@@ -256,18 +264,9 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +278,9 @@ fun ItemContextMenu(
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +291,17 @@ fun ItemContextMenu(
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -342,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistItems(
|
||||
artists: List<ArtistRef>,
|
||||
actions: ItemActions,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||
dismiss: (() -> Unit) -> Unit,
|
||||
replaceWith: (() -> Unit) -> Unit,
|
||||
) {
|
||||
val view = actions.viewArtist ?: return
|
||||
val distinct = artists.distinctBy { it.id }
|
||||
when (distinct.size) {
|
||||
0 -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val artist = distinct.first()
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (onChooseArtists == null) {
|
||||
distinct.forEach { artist ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val fallback = stringResource(R.string.resources_filter_artist)
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||
) {
|
||||
replaceWith { onChooseArtists(distinct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadItem(
|
||||
download: SongDownload?,
|
||||
@@ -439,11 +487,19 @@ private fun MenuItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||
trailingContent = trailing,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
@Composable
|
||||
fun AlbumInfoDialog(
|
||||
album: Album,
|
||||
download: ListDownloadStatus?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||
download?.takeIf { it.total > 0 }?.let { status ->
|
||||
InfoRow(
|
||||
stringResource(R.string.status_downloaded),
|
||||
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||
)
|
||||
}
|
||||
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistInfoDialog(
|
||||
artist: Artist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaylistInfoDialog(
|
||||
playlist: Playlist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||
|
||||
@Composable
|
||||
internal fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ArtistRef>.names(): String? =
|
||||
map { it.name }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(ARTIST_SEPARATOR)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun formatDate(
|
||||
context: Context,
|
||||
epochSeconds: Long,
|
||||
): String =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
epochSeconds * 1000,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||
)
|
||||
|
||||
private val INFO_LABEL_WIDTH = 100.dp
|
||||
private val INFO_LABEL_GAP = 12.dp
|
||||
@@ -1,12 +1,28 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
internal fun Song.artistSubtitle(): String? {
|
||||
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
|
||||
|
||||
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
|
||||
|
||||
internal fun artistNames(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
): List<String> {
|
||||
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||
if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() }
|
||||
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
|
||||
}
|
||||
|
||||
internal fun artistSubtitle(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
): String? {
|
||||
val names = artistNames(artist, artists)
|
||||
if (names.isEmpty()) return null
|
||||
val primary = artist?.takeIf { it in names }
|
||||
return buildList {
|
||||
primary?.let(::add)
|
||||
|
||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val multipleArtists = song.artistNames().size > 1
|
||||
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artistSubtitle())
|
||||
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
|
||||
InfoRow(
|
||||
stringResource(
|
||||
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
|
||||
),
|
||||
song.artistSubtitle(),
|
||||
)
|
||||
InfoRow(
|
||||
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
|
||||
song.artistSort.takeIfDifferentFrom(regularArtistSort),
|
||||
)
|
||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(
|
||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val INFO_LABEL_WIDTH = 80.dp
|
||||
|
||||
@Composable
|
||||
private fun downloadStatus(status: DownloadStatus): String =
|
||||
when (status) {
|
||||
|
||||
@@ -67,9 +67,9 @@ fun AlbumDetailRoute(
|
||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
@@ -215,6 +215,7 @@ fun AlbumDetailScreen(
|
||||
artwork = artwork,
|
||||
title = album?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.isNotEmpty(),
|
||||
header = { controlsModifier, topInset ->
|
||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||
HeroHeader(
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
val album: StateFlow<Album?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<List<Song>> =
|
||||
val songs: StateFlow<List<Song>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
val discs: StateFlow<List<Disc>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
@@ -55,10 +56,13 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -191,9 +195,9 @@ fun ArtistDetailRoute(
|
||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||
val bioSnapshots =
|
||||
remember(viewModel) {
|
||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||
@@ -302,7 +306,7 @@ fun ArtistDetailScreen(
|
||||
onCollapseBio: () -> Unit = {},
|
||||
onShuffle: () -> Unit = {},
|
||||
) {
|
||||
val listState = rememberLazyGridState()
|
||||
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val lineHeight =
|
||||
@@ -349,8 +353,8 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
@@ -570,6 +574,31 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||
val state = rememberLazyGridState()
|
||||
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||
LaunchedEffect(contentReady) {
|
||||
if (contentReady && pending) {
|
||||
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||
state.scrollToItem(target, offset)
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||
.collect { (currentIndex, currentOffset) ->
|
||||
if (!pending) {
|
||||
index = currentIndex
|
||||
offset = currentOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||
@Composable
|
||||
private fun ArtistBio(
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -26,8 +25,6 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -41,23 +38,26 @@ class ArtistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
val artist: StateFlow<Artist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val artistName: Flow<String?> =
|
||||
sourceId.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}
|
||||
val artistName: StateFlow<String?> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val albums: Flow<ArtistAlbums?> =
|
||||
val albums: StateFlow<ArtistAlbums?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.map<ArtistAlbums, ArtistAlbums?> { it }
|
||||
.onStart { emit(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
@@ -69,7 +69,10 @@ class ArtistDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
val artistBio: Flow<ArtistBio?> = sourceId.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||
val artistBio: StateFlow<ArtistBio?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _bioLoading = MutableStateFlow(true)
|
||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||
@@ -142,15 +145,24 @@ class ArtistDetailViewModel(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
artist.filterNotNull(),
|
||||
sourceRepository.offline,
|
||||
sourceRepository.activeSource,
|
||||
) { loaded, _, activeSource -> loaded to activeSource }
|
||||
.collectLatest { (loaded, activeSource) ->
|
||||
if (activeSource == null) return@collectLatest
|
||||
val hero = sourceRepository.artistArt(loaded, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(loaded, forIcon = true)
|
||||
sourceRepository.activeSource
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(
|
||||
libraryRepository.artist(sourceId, artistId),
|
||||
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||
sourceRepository.offline,
|
||||
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||
}.collectLatest { input ->
|
||||
val resolved = resolveArtistArtInput(input)
|
||||
if (resolved == null) {
|
||||
_art.value = null
|
||||
_artThumbnail.value = null
|
||||
_heroArt.value = null
|
||||
return@collectLatest
|
||||
}
|
||||
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||
_heroArt.value = hero
|
||||
@@ -169,3 +181,35 @@ class ArtistDetailViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ArtistArtInput(
|
||||
val artist: Artist?,
|
||||
val artistId: String,
|
||||
val offline: Boolean,
|
||||
val sourceId: Long,
|
||||
val covers: List<String>,
|
||||
)
|
||||
|
||||
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||
val artist = input.artist
|
||||
if (input.covers.isEmpty()) return artist
|
||||
if (artist == null) {
|
||||
return Artist(
|
||||
sourceId = input.sourceId,
|
||||
id = input.artistId,
|
||||
name = "",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArts = input.covers,
|
||||
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||
)
|
||||
}
|
||||
return if (input.offline) {
|
||||
artist.copy(
|
||||
coverArts = input.covers,
|
||||
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||
)
|
||||
} else {
|
||||
artist.copy(coverArts = input.covers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -43,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -71,7 +74,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
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.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
@@ -94,12 +96,14 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.PagingData
|
||||
@@ -592,8 +596,6 @@ fun LibraryScreen(
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
@@ -615,11 +617,29 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
if (!searchActive) {
|
||||
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||
if (offline) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onExitOffline,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||
.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
val searchFab = selectedTab == LibraryTab.Home
|
||||
FloatingActionButton(
|
||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
||||
containerColor = fabContainerColor,
|
||||
contentColor = fabContentColor,
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||
) {
|
||||
@@ -638,11 +658,18 @@ fun LibraryScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
.offset(x = 1.dp, y = 2.5.dp)
|
||||
.size(11.dp)
|
||||
.background(fabContainerColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.background(fabContentColor, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,7 +729,7 @@ private fun ListOptionsSheet(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -824,6 +851,7 @@ private fun ListOptionsSheet(
|
||||
|
||||
private val BOTTOM_CLEARANCE = 80.dp
|
||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||
private val TAB_ICON_SIZE = 24.dp
|
||||
private val TAB_VERTICAL_PADDING = 6.dp
|
||||
private val TOUCH_TARGET = 48.dp
|
||||
@@ -856,13 +884,12 @@ internal fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val page = pagerState.currentPage
|
||||
val fraction = pagerState.currentPageOffsetFraction
|
||||
@@ -894,79 +921,113 @@ internal fun LibraryTabs(
|
||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||
var contentWidth by remember { mutableIntStateOf(0) }
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||
if (viewportWidth == 0) return@LaunchedEffect
|
||||
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||
val target =
|
||||
when {
|
||||
start - leadingGap < scrollState.value -> start - leadingGap
|
||||
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||
}
|
||||
revealed = true
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(headerColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawBehind {
|
||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { viewportWidth = it.width }
|
||||
.horizontalScroll(scrollState)
|
||||
.onSizeChanged { contentWidth = it.width },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawBehind {
|
||||
if (current != null &&
|
||||
indicatorLeft != null &&
|
||||
indicatorRight != null &&
|
||||
indicatorRight > indicatorLeft
|
||||
) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (offline) {
|
||||
IconButton(onClick = onExitOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
|
||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
|
||||
artwork = artwork,
|
||||
title = playlist?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.itemCount > 0,
|
||||
header = { controlsModifier, topInset ->
|
||||
HeroHeader(
|
||||
art = coverArt(playlist?.coverArt, false),
|
||||
|
||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
val playlist: StateFlow<Playlist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.playerSurfaceColor
|
||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
Text(
|
||||
text = item.artist ?: item.album.orEmpty(),
|
||||
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
@@ -576,7 +577,10 @@ fun NowPlayingScreen(
|
||||
}
|
||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||
Text(
|
||||
text = state.item?.artist.orEmpty(),
|
||||
text =
|
||||
state.item
|
||||
?.let { artistSubtitle(it.artist, it.artists) }
|
||||
.orEmpty(),
|
||||
style = subtitleStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
|
||||
SyncMode,
|
||||
ReplayGain,
|
||||
ReplayGainPreamp,
|
||||
SortNames,
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -128,6 +130,7 @@ fun SettingsRoute(
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
@@ -143,6 +146,7 @@ fun SettingsRoute(
|
||||
scrobbling = scrobbling,
|
||||
replayGainMode = replayGainMode,
|
||||
replayGainPreamp = replayGainPreamp,
|
||||
sortNameMode = sortNameMode,
|
||||
keepScreenOn = keepScreenOn,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
@@ -158,6 +162,7 @@ fun SettingsRoute(
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||
onSortNameModeChange = viewModel::setSortNameMode,
|
||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
@@ -205,6 +210,7 @@ fun SettingsScreen(
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||
replayGainPreamp: Float = 0f,
|
||||
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -217,6 +223,7 @@ fun SettingsScreen(
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
@@ -348,6 +355,15 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_library_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -603,6 +619,14 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SortNames -> {
|
||||
SortNameModeDialog(
|
||||
selected = sortNameMode,
|
||||
onSelect = onSortNameModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -756,6 +780,52 @@ private fun SyncModeDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val sortNameModeOptions =
|
||||
listOf(SortNameMode.Name, SortNameMode.SortName, SortNameMode.NonLatin)
|
||||
|
||||
private fun sortNameModeLabel(
|
||||
resources: Resources,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name)
|
||||
SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name)
|
||||
SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortNameModeDialog(
|
||||
selected: SortNameMode,
|
||||
onSelect: (SortNameMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.settings_sort_names_prompt),
|
||||
options = sortNameModeOptions.map { it to sortNameModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayGainModeDialog(
|
||||
selected: ReplayGainMode,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -76,6 +77,11 @@ class SettingsViewModel(
|
||||
.replayGainPreampDb()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||
|
||||
val sortNameMode: StateFlow<SortNameMode> =
|
||||
userPreferences
|
||||
.sortNameMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||
|
||||
val keepScreenOn: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.keepScreenOn()
|
||||
@@ -143,4 +149,8 @@ class SettingsViewModel(
|
||||
fun setKeepScreenOn(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||
}
|
||||
|
||||
fun setSortNameMode(mode: SortNameMode) {
|
||||
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_library_name">Library</string>
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||
@@ -96,6 +97,11 @@
|
||||
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
||||
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
||||
<string name="settings_sort_names_title">Sort names</string>
|
||||
<string name="settings_sort_names_prompt">Choose how names are ordered in album and artist lists.</string>
|
||||
<string name="sort_name_mode_sort_name">Use sort names</string>
|
||||
<string name="sort_name_mode_name">Use regular names</string>
|
||||
<string name="sort_name_mode_non_latin">Non-Latin names only</string>
|
||||
<string name="settings_network_name">Network</string>
|
||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||
@@ -191,6 +197,24 @@
|
||||
<string name="info_sample_rate_value">%1$d Hz</string>
|
||||
<string name="info_channels">Channels</string>
|
||||
<string name="info_close">Close</string>
|
||||
<string name="info_artists">Artists</string>
|
||||
<string name="info_artists_sort">Artists sort</string>
|
||||
<string name="info_album_artists">Album artists</string>
|
||||
<string name="info_album_artist_sort">Album artist sort</string>
|
||||
<string name="info_sort_title">Sort title</string>
|
||||
<string name="info_artist_sort">Artist sort</string>
|
||||
<string name="info_sort_name">Sort name</string>
|
||||
<string name="info_albums">Albums</string>
|
||||
<string name="info_songs">Songs</string>
|
||||
<string name="info_plays">Plays</string>
|
||||
<string name="info_last_played">Last played</string>
|
||||
<string name="info_added">Added</string>
|
||||
<string name="info_created">Created</string>
|
||||
<string name="info_updated">Updated</string>
|
||||
<string name="info_owner">Owner</string>
|
||||
<string name="info_shared">Shared</string>
|
||||
<string name="info_length">Length</string>
|
||||
<string name="info_downloaded_count">%1$d of %2$d</string>
|
||||
|
||||
<string name="disc_header">Disc %1$d</string>
|
||||
<string name="disc_track">%1$s: Disc %2$d</string>
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
@@ -332,10 +333,18 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
ALBUM_ORDER_BY_NAME to "index_albums_name",
|
||||
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name",
|
||||
ALBUM_ORDER_BY_ARTIST to "index_albums_artist",
|
||||
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist",
|
||||
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_name",
|
||||
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_name",
|
||||
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_name_plain",
|
||||
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_name_plain",
|
||||
"${albumArtistSortKey(SortNameMode.SortName)}, year, " +
|
||||
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_artist",
|
||||
"${albumArtistSortKey(SortNameMode.SortName)} DESC, year DESC, " +
|
||||
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_artist",
|
||||
"${albumArtistSortKey(SortNameMode.Name)}, year, " +
|
||||
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_artist_plain",
|
||||
"${albumArtistSortKey(SortNameMode.Name)} DESC, year DESC, " +
|
||||
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_artist_plain",
|
||||
ALBUM_ORDER_BY_YEAR to "index_albums_year",
|
||||
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
|
||||
ALBUM_ORDER_BY_ADDED to "index_albums_added",
|
||||
@@ -359,8 +368,10 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
ARTIST_ORDER_BY_NAME to "index_artists_name",
|
||||
ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
|
||||
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||
@@ -644,6 +655,87 @@ class LibraryDaoTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coverArtFallbackFindsEveryCreditPath() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Album", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "track-art"),
|
||||
album(sourceId, "al-2", "Featured", year = 2021, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "featured-art"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s-1", "Song", starred = null)
|
||||
.copy(albumId = "al-1", artistId = "ar-missing"),
|
||||
song(sourceId, "s-2", "Featured", starred = null)
|
||||
.copy(albumId = "al-2", artistId = "ar-other"),
|
||||
),
|
||||
)
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (?, 'al-2', 0, 'ar-featured', 'Featured')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (?, 's-2', 0, 'ar-guest', 'Guest')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
assertNull(dao.artistOnce(sourceId, "ar-missing"))
|
||||
assertNull(dao.artistOnce(sourceId, "ar-featured"))
|
||||
assertNull(dao.artistOnce(sourceId, "ar-guest"))
|
||||
assertEquals(listOf("track-art"), dao.artistCoverArts(sourceId, "ar-missing").first())
|
||||
assertEquals(listOf("featured-art", "track-art"), dao.artistCoverArts(sourceId, "ar-other").first())
|
||||
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-featured").first())
|
||||
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-guest").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistCoverArtsRanksDeduplicatesCapsAndStaysInSource() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val otherSourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-rank", "Rank", albumCount = 0)))
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a-new", "New", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "shared"),
|
||||
album(sourceId, "a-mid", "Mid", year = 2015, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "other"),
|
||||
album(sourceId, "a-old", "Old", year = 2010, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "shared"),
|
||||
album(sourceId, "a-4", "Four", year = 2012, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "c4"),
|
||||
album(sourceId, "a-5", "Five", year = 2011, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "c5"),
|
||||
album(sourceId, "a-6", "Six", year = 2009, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "c6"),
|
||||
album(otherSourceId, "a-leak", "Leak", year = 2025, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "leak"),
|
||||
),
|
||||
)
|
||||
|
||||
val expected = listOf("shared", "other", "c4", "c5")
|
||||
assertEquals(expected, dao.artistCoverArts(sourceId, "ar-rank").first())
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
assertEquals(expected, dao.artistOnce(sourceId, "ar-rank")!!.coverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
|
||||
runTest {
|
||||
@@ -924,7 +1016,12 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "List", created = 0, changed = 0).copy(songCount = 5)))
|
||||
dao.upsertPlaylists(
|
||||
listOf(
|
||||
playlist(sourceId, "pl-1", "List", created = 0, changed = 0)
|
||||
.copy(songCount = 5, owner = "austin", shared = true),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null),
|
||||
@@ -942,6 +1039,8 @@ class LibraryDaoTest {
|
||||
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals(1L, filtered.first().songCount)
|
||||
assertEquals("austin", filtered.first().owner)
|
||||
assertEquals(true, filtered.first().shared)
|
||||
|
||||
assertEquals(
|
||||
5L,
|
||||
@@ -1055,7 +1154,7 @@ class LibraryDaoTest {
|
||||
val plan =
|
||||
plan(
|
||||
"SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " +
|
||||
"ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0",
|
||||
"ORDER BY ${albumNameSortKey(SortNameMode.SortName)}, id LIMIT 20 OFFSET 0",
|
||||
)
|
||||
|
||||
assertTrue(plan, plan.contains("album_search"))
|
||||
@@ -1320,6 +1419,70 @@ class LibraryDaoTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNamePreferenceChoosesTheOrderKey() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
|
||||
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
|
||||
artist(sourceId, "ar-3", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
|
||||
artist(sourceId, "ar-4", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Zzz", year = null, starred = null)
|
||||
.copy(sortName = "Aaa", albumArtistSort = "Zzz", albumArtist = "Beatles"),
|
||||
album(sourceId, "al-2", "Aaa", year = null, starred = null)
|
||||
.copy(sortName = "Zzz", albumArtistSort = "Aaa", albumArtist = "Aaa"),
|
||||
album(sourceId, "al-3", "Beatles", year = null, starred = null)
|
||||
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Zebra"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
|
||||
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Beatles", "Zzz"),
|
||||
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz", "Beatles"),
|
||||
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Beatles", "Zzz"),
|
||||
dao
|
||||
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
|
||||
runTest {
|
||||
|
||||
@@ -51,6 +51,22 @@ class UserPreferencesTest {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNameModeRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(SortNameMode.Name, prefs.sortNameMode().first())
|
||||
|
||||
prefs.setSortNameMode(SortNameMode.NonLatin)
|
||||
|
||||
assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSelectedLibraryTabRoundTrips() =
|
||||
runTest {
|
||||
|
||||
@@ -76,6 +76,7 @@ class LibraryRepositoryTest {
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
showMessage = { messages += it.resolve(resources) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
@@ -745,6 +745,29 @@ class SubsonicXmlTest {
|
||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsPlaylistOwnerAndSharing() {
|
||||
val playlists =
|
||||
parse(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<playlists>
|
||||
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
|
||||
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
|
||||
<playlist id="pl3" name="Numeric" owner="austin" public="1" songCount="1" duration="60"/>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
tag = "playlist",
|
||||
create = { SubsonicXml.playlist(1, it) },
|
||||
)
|
||||
|
||||
assertEquals("austin", playlists[0].owner)
|
||||
assertTrue(playlists[0].shared)
|
||||
assertEquals(false, playlists[1].shared)
|
||||
assertTrue(playlists[2].shared)
|
||||
}
|
||||
|
||||
private class CountingInputStream(
|
||||
input: InputStream,
|
||||
) : FilterInputStream(input) {
|
||||
|
||||
@@ -258,6 +258,19 @@ class SyncServiceTest {
|
||||
assertEquals(5678L, stored?.starred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresPlaylistOwnerAndSharing() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source = FakeMusicSource(playlists = listOf(playlist("p1").copy(owner = "austin", shared = true)))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val stored = db.libraryDao().playlist(1, "p1").first()
|
||||
assertEquals("austin", stored?.owner)
|
||||
assertEquals(true, stored?.shared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresSongGenresInOrder() =
|
||||
runTest {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.playback
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -70,6 +71,21 @@ class MediaItemTest {
|
||||
assertEquals(gain, item.replayGain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesEveryArtist() {
|
||||
val artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B"))
|
||||
|
||||
val item =
|
||||
queueMediaItem(
|
||||
song.copy(artists = artists),
|
||||
local = null,
|
||||
stream = "http://server/stream?s=s1",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
assertEquals(artists, item.artistRefs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesItsGainContext() {
|
||||
val item =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -57,6 +58,29 @@ class QueueItemTest {
|
||||
assertEquals(true, item.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carriesEveryArtist() {
|
||||
val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "A • B",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
)
|
||||
|
||||
val item = AlbumSongItem(song, coverArt = null).toQueueItem()
|
||||
|
||||
assertEquals(listOf("A", "B"), item.artists.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithoutGainCarriesNoGain() {
|
||||
val item =
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.subtracks.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
@@ -16,6 +18,9 @@ import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeDown
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStates
|
||||
import androidx.paging.PagingData
|
||||
@@ -342,6 +347,204 @@ class LibraryScreenTest {
|
||||
assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun aLongTabKeepsTheGearFullSizeAndScrollsIntoView() {
|
||||
var labelWidth = 0f
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
labelWidth =
|
||||
rememberTextMeasurer()
|
||||
.measure(
|
||||
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
).size.width
|
||||
.toFloat()
|
||||
LibraryTabs(
|
||||
pagerState =
|
||||
remember {
|
||||
PagerState(
|
||||
currentPage = LibraryTab.Playlists.ordinal,
|
||||
pageCount = { LibraryTab.entries.size },
|
||||
)
|
||||
},
|
||||
onTabSelected = {},
|
||||
onOpenSettings = {},
|
||||
artwork = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||
.assertWidthIsEqualTo(40.dp)
|
||||
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "ar-rEG-w320dp-h640dp-xhdpi")
|
||||
fun aLongTabScrollsIntoViewInRtl() {
|
||||
var labelWidth = 0f
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
labelWidth =
|
||||
rememberTextMeasurer()
|
||||
.measure(
|
||||
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
).size.width
|
||||
.toFloat()
|
||||
LibraryTabs(
|
||||
pagerState =
|
||||
remember {
|
||||
PagerState(
|
||||
currentPage = LibraryTab.Playlists.ordinal,
|
||||
pageCount = { LibraryTab.entries.size },
|
||||
)
|
||||
},
|
||||
onTabSelected = {},
|
||||
onOpenSettings = {},
|
||||
artwork = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||
.assertWidthIsEqualTo(40.dp)
|
||||
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun movingToALongTabScrollsItIntoView() {
|
||||
val selectedTab = mutableStateOf(LibraryTab.Home)
|
||||
var labelWidth = 0f
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
labelWidth =
|
||||
rememberTextMeasurer()
|
||||
.measure(
|
||||
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
).size.width
|
||||
.toFloat()
|
||||
LibraryScreen(
|
||||
selectedTab = selectedTab.value,
|
||||
onTabSelected = { selectedTab.value = it },
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.runOnIdle { selectedTab.value = LibraryTab.Playlists }
|
||||
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
|
||||
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Playlists).width >= expected - 1f }
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||
.assertWidthIsEqualTo(40.dp)
|
||||
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun returningToTheFirstTabLeavesAGapAtTheEdge() {
|
||||
val selectedTab = mutableStateOf(LibraryTab.Playlists)
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = selectedTab.value,
|
||||
onTabSelected = { selectedTab.value = it },
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.runOnIdle { selectedTab.value = LibraryTab.Home }
|
||||
val expectedGap = with(composeRule.density) { TAB_EDGE_GAP.toPx() }
|
||||
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Home).left <= expectedGap + 1f }
|
||||
|
||||
assertEquals(
|
||||
"the first tab should keep a gap from the screen edge",
|
||||
expectedGap,
|
||||
tabBounds(LibraryTab.Home).left,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertTabFullyVisible(
|
||||
tab: LibraryTab,
|
||||
labelWidth: Float,
|
||||
) {
|
||||
val bounds = tabBounds(tab)
|
||||
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
|
||||
assertEquals("the selected tab should be shown in full, but was $bounds", expected, bounds.width, 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun theOfflineIndicatorGoesOnline() {
|
||||
var exited = false
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Playlists,
|
||||
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 = {},
|
||||
offline = true,
|
||||
onExitOffline = { exited = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.offline_mode_status))
|
||||
.performClick()
|
||||
assertTrue("the offline indicator should leave offline mode", exited)
|
||||
}
|
||||
|
||||
private fun tabBounds(tab: LibraryTab) =
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(tab.label))
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
|
||||
private val settledLoadStates =
|
||||
LoadStates(
|
||||
refresh = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
@@ -378,3 +581,6 @@ class LibraryScreenTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TAB_LABEL_CHROME = 56.dp
|
||||
private val TAB_EDGE_GAP = 8.dp
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasScrollAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HeroDetailScaffoldRestoreTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun aRestoredScreenWaitsForItsRowsBeforeRestoringTheScrollPosition() {
|
||||
var visible by mutableStateOf(true)
|
||||
var rows by mutableIntStateOf(100)
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val holder = rememberSaveableStateHolder()
|
||||
if (visible) {
|
||||
holder.SaveableStateProvider("detail") {
|
||||
HeroDetailScaffold(
|
||||
artwork = null,
|
||||
title = "Album",
|
||||
onBack = {},
|
||||
header = { _, _ -> Box(Modifier.height(200.dp)) },
|
||||
content = { rowModifier -> rowItems(rows, rowModifier) },
|
||||
contentReady = rows > 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Item 20"))
|
||||
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
|
||||
|
||||
composeRule.runOnIdle { visible = false }
|
||||
composeRule.runOnIdle {
|
||||
rows = 0
|
||||
visible = true
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.runOnIdle { rows = 100 }
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun LazyListScope.rowItems(
|
||||
count: Int,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
repeat(count) { index ->
|
||||
item(key = index) {
|
||||
Box(modifier.height(48.dp)) {
|
||||
Text("Item $index")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,18 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filter
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -44,34 +47,69 @@ class ItemContextMenuArtistTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
|
||||
var opened: String? = null
|
||||
fun pluralArtistsShareOneEntryThatOpensTheChooser() {
|
||||
var opened: List<ArtistRef>? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
var show by remember { mutableStateOf(true) }
|
||||
if (show) {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Song(song),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
actions = ItemActions(viewArtist = {}),
|
||||
onDismiss = { show = false },
|
||||
onChooseArtists = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
||||
val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction())
|
||||
entry.assertIsDisplayed()
|
||||
assertTrue(
|
||||
composeRule
|
||||
.onAllNodesWithText("A")
|
||||
.filter(hasClickAction())
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
composeRule
|
||||
.onAllNodesWithText("B")
|
||||
.filter(hasClickAction())
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty(),
|
||||
)
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("B").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
entry.performClick()
|
||||
|
||||
assertEquals("ar2", opened)
|
||||
assertEquals(listOf("ar1", "ar2"), opened?.map { it.id })
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longMenusScrollToTheLastArtist() {
|
||||
fun aSingleArtistOpensDirectly() {
|
||||
var opened: String? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Song(song.copy(artists = listOf(ArtistRef("ar1", "A")))),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onAllNodesWithText("A").filterToOne(hasClickAction()).performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("ar1", opened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manyArtistsStillShareOneEntry() {
|
||||
var opened: List<ArtistRef>? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
@@ -79,18 +117,23 @@ class ItemContextMenuArtistTest {
|
||||
MenuTarget.Song(
|
||||
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
||||
),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
actions = ItemActions(viewArtist = {}),
|
||||
onDismiss = {},
|
||||
onChooseArtists = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
|
||||
val entry =
|
||||
composeRule
|
||||
.onAllNodesWithText((1..20).joinToString(" · ") { "Artist $it" })
|
||||
.filterToOne(hasClickAction())
|
||||
entry.assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("Artist 20").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
entry.performClick()
|
||||
|
||||
assertEquals("ar20", opened)
|
||||
assertEquals(20, opened?.size)
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
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.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
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.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ItemContextMenuInfoTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun anAlbumMenuOpensItsInfo() {
|
||||
var opened: MenuTarget? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Album(album),
|
||||
actions = ItemActions(),
|
||||
onDismiss = {},
|
||||
onInfo = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Info").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals(album, (opened as? MenuTarget.Album)?.album)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistMenuOpensItsInfo() {
|
||||
var opened: MenuTarget? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target =
|
||||
MenuTarget.Artist(
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
),
|
||||
),
|
||||
actions = ItemActions(),
|
||||
onDismiss = {},
|
||||
onInfo = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Info").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("ar1", (opened as? MenuTarget.Artist)?.artist?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaylistMenuOpensItsInfo() {
|
||||
var opened: MenuTarget? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target =
|
||||
MenuTarget.Playlist(
|
||||
Playlist(
|
||||
sourceId = 1,
|
||||
id = "pl1",
|
||||
name = "Playlist",
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
created = 0,
|
||||
),
|
||||
),
|
||||
actions = ItemActions(),
|
||||
onDismiss = {},
|
||||
onInfo = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Info").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("pl1", (opened as? MenuTarget.Playlist)?.playlist?.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
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.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
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.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class LibraryInfoDialogTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun anAlbumShowsEveryArtistAndBothSortNames() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
AlbumInfoDialog(
|
||||
album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "A",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = "Rock",
|
||||
year = 1999,
|
||||
starred = null,
|
||||
songCount = 12,
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
sortName = "Album, The",
|
||||
albumArtistSort = "A, The",
|
||||
),
|
||||
download = null,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("A · B").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Album, The").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("A, The").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("1999").assertIsDisplayed()
|
||||
|
||||
val label = composeRule.onNodeWithText("Album artist sort").fetchSemanticsNode().boundsInRoot
|
||||
val value = composeRule.onNodeWithText("A, The").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("the value must be separated from its label", value.left > label.right)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistShowsItsSortNameAndCounts() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistInfoDialog(
|
||||
artist =
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "The Artist",
|
||||
albumCount = 4,
|
||||
starred = null,
|
||||
songCount = 40,
|
||||
sortName = "Artist, The",
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist, The").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("4").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("40").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistHidesASortNameThatMatchesItsName() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistInfoDialog(
|
||||
artist =
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "The Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
sortName = "the artist",
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Sort name").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlbumHidesSortNamesThatMatchTheRegularNames() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
AlbumInfoDialog(
|
||||
album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
sortName = "album",
|
||||
albumArtistSort = "artist",
|
||||
),
|
||||
download = null,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Album artist sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlbumsDatesAreListedTogether() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
AlbumInfoDialog(
|
||||
album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 86400,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
playCount = 3,
|
||||
played = 172800,
|
||||
),
|
||||
download = null,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val added = composeRule.onNodeWithText("Added").fetchSemanticsNode().boundsInRoot
|
||||
val lastPlayed = composeRule.onNodeWithText("Last played").fetchSemanticsNode().boundsInRoot
|
||||
val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("last played must follow added", lastPlayed.top >= added.bottom)
|
||||
assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom)
|
||||
assertEquals(
|
||||
"no row may sit between added and last played",
|
||||
lastPlayed.top - added.top,
|
||||
plays.top - lastPlayed.top,
|
||||
0.5f,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaylistShowsOwnerSharingLengthAndDates() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
PlaylistInfoDialog(
|
||||
playlist =
|
||||
Playlist(
|
||||
sourceId = 1,
|
||||
id = "pl1",
|
||||
name = "Mix",
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 12,
|
||||
created = 1600000000,
|
||||
changed = 1700000000,
|
||||
duration = 3725,
|
||||
owner = "austin",
|
||||
shared = true,
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Owner").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("austin").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Shared").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Yes").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("12").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("1:02:05").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Created").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Updated").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleArtistTrackUsesSingularLabels() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song(artists = listOf(ArtistRef("ar1", "Alpha")), artistSort = "Beta"),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateAndBlankArtistRefsStaySingular() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song =
|
||||
song(
|
||||
artists = listOf(ArtistRef("ar1", "Alpha"), ArtistRef("ar2", "Alpha"), ArtistRef("ar3", "")),
|
||||
artistSort = "Beta",
|
||||
),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMixedArtistTrackUsesPluralLabelsForBothRows() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song =
|
||||
song(
|
||||
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
|
||||
artistSort = "Alpha",
|
||||
),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists sort").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTrackHidesATitleAndArtistSortThatMatchTheRegularNames() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song(artists = listOf(ArtistRef("ar1", "Artist")), sortName = "song", artistSort = "artist"),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMixedArtistTrackWithoutASortShowsOnlyTheArtistsRow() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song =
|
||||
song(
|
||||
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
|
||||
artistSort = "",
|
||||
),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist A · Artist B").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTrackWithOnlyAnArtistSortShowsOnlyTheSortRow() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song(artists = emptyList(), artist = null, artistSort = "Alpha"),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun song(
|
||||
artists: List<ArtistRef>,
|
||||
artist: String? = artists.firstOrNull()?.name,
|
||||
sortName: String = "Song",
|
||||
artistSort: String = artist.orEmpty(),
|
||||
) = Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = artists.firstOrNull()?.id,
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = artist,
|
||||
duration = 200,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = artists,
|
||||
sortName = sortName,
|
||||
artistSort = artistSort,
|
||||
)
|
||||
}
|
||||
@@ -90,7 +90,13 @@ class DownloadsViewModelTest {
|
||||
)
|
||||
viewModel =
|
||||
DownloadsViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
),
|
||||
downloadRepository,
|
||||
controller,
|
||||
"Unknown artist",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import com.subtracks.data.model.Artist
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ArtistDetailArtTest {
|
||||
@Test
|
||||
fun aCreditedArtistWithoutARowUsesTheCreditCovers() {
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = null,
|
||||
artistId = "ar1",
|
||||
offline = false,
|
||||
sourceId = 7,
|
||||
covers = listOf("a", "b"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(7L, resolved?.sourceId)
|
||||
assertEquals("ar1", resolved?.id)
|
||||
assertEquals(listOf("a", "b"), resolved?.coverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOfflineArtistKeepsItsDownloadedCovers() {
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = artist(coverArts = listOf("online"), downloadedCoverArts = listOf("downloaded")),
|
||||
artistId = "ar1",
|
||||
offline = true,
|
||||
sourceId = 7,
|
||||
covers = listOf("online", "other"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("online", "other"), resolved?.coverArts)
|
||||
assertEquals(listOf("downloaded"), resolved?.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOfflineArtistWithoutDownloadedCoversFallsBackToTheCreditCovers() {
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = artist(downloadedCoverArts = emptyList()),
|
||||
artistId = "ar1",
|
||||
offline = true,
|
||||
sourceId = 7,
|
||||
covers = listOf("a", "b"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("a", "b"), resolved?.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistWithMaterializedCoversIsKeptWhenTheCreditQueryIsEmpty() {
|
||||
val original = artist(coverArts = listOf("materialized"))
|
||||
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = original,
|
||||
artistId = "ar1",
|
||||
offline = false,
|
||||
sourceId = 7,
|
||||
covers = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(original, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noArtistAndNoCoversClearsTheArt() {
|
||||
assertNull(
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = null,
|
||||
artistId = "ar1",
|
||||
offline = false,
|
||||
sourceId = 7,
|
||||
covers = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun artist(
|
||||
coverArts: List<String> = emptyList(),
|
||||
downloadedCoverArts: List<String> = emptyList(),
|
||||
) = Artist(
|
||||
sourceId = 7,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArts = coverArts,
|
||||
downloadedCoverArts = downloadedCoverArts,
|
||||
)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class NowPlayingRouteTest {
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
).also { it.start() }
|
||||
library = LibraryRepository(db, sources, serverActions, scope = repoScope)
|
||||
library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
|
||||
handle = FakePlayerHandle()
|
||||
controller =
|
||||
PlaybackController(
|
||||
|
||||
@@ -90,7 +90,13 @@ class SearchViewModelTest {
|
||||
)
|
||||
viewModel =
|
||||
SearchViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
),
|
||||
sourceRepository,
|
||||
controller,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -31,7 +32,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
@@ -42,7 +43,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOff() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = true) { requested = it }
|
||||
renderSettings(keepScreenOn = true, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
@@ -53,7 +54,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule
|
||||
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
|
||||
@@ -63,9 +64,29 @@ class SettingsScreenTest {
|
||||
assertEquals(true, requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNamesRowChangesTheSortNameMode() {
|
||||
var requested: SortNameMode? = null
|
||||
renderSettings(
|
||||
keepScreenOn = false,
|
||||
onKeepScreenOnChange = {},
|
||||
onSortNameModeChange = { requested = it },
|
||||
scrollTo = "Sort names",
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Sort names").performClick()
|
||||
composeRule.onNodeWithText("Use sort names").performClick()
|
||||
composeRule.onNodeWithText("Done").performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(SortNameMode.SortName, requested)
|
||||
}
|
||||
|
||||
private fun renderSettings(
|
||||
keepScreenOn: Boolean,
|
||||
onKeepScreenOnChange: (Boolean) -> Unit,
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
scrollTo: String = "Keep screen on",
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
@@ -88,6 +109,7 @@ class SettingsScreenTest {
|
||||
onDownloadOverMeteredChange = {},
|
||||
onScrobblingChange = {},
|
||||
onKeepScreenOnChange = onKeepScreenOnChange,
|
||||
onSortNameModeChange = onSortNameModeChange,
|
||||
onAddServer = {},
|
||||
onOpenDownloads = {},
|
||||
onOpenLicenses = {},
|
||||
@@ -97,7 +119,7 @@ class SettingsScreenTest {
|
||||
}
|
||||
}
|
||||
awaitText("Add server")
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(scrollTo))
|
||||
}
|
||||
|
||||
private fun awaitText(text: String) {
|
||||
|
||||
Reference in New Issue
Block a user