30 Commits
Author SHA1 Message Date
austinried f9151843a6 Revert "ui: show the singular and plural artist rows separately"
This reverts commit 2f95a89682.
2026-10-21 14:24:48 +00:00
austinried 2f95a89682 ui: show the singular and plural artist rows separately
Track info now lists the Artist field and the plural Artists field as
their own rows, hiding Artists when it would repeat the Artist value.
Keep one Artist sort row, also hidden when it matches the artist name.
2026-10-21 14:24:18 +00:00
austinried 319ff4b645 test: pin independent artist and artist sort rows
The info rows do not depend on each other: a track with artists but no
artist sort keeps the artists row, and a track with an artist sort but
no artist name still shows the sort row. Pin both so a later label or
value change cannot start requiring both.
2026-10-21 14:15:40 +00:00
austinried d8ac7fefc7 db: keep playlist owner and sharing in filtered lists
The hand-written playlist projection used by the Downloaded filter and
offline mode did not select the new owner and shared columns, so the
playlist Info dialog lost them there while unfiltered lists had them.
Select both columns and pin the filtered path in LibraryDaoTest.

Also share the artist-name extraction between the subtitle and the
plural label so duplicate or blank refs cannot make them disagree,
accept the XML boolean form "1" for the public flag, and strengthen the
date-adjacency and pluralisation tests.
2026-10-21 14:04:46 +00:00
austinried f46f43b6cd ui: finish the library info dialogs and add playlist info
Sort names and titles are only shown when they differ from the regular
name, so artists without a distinct sort name no longer repeat the name.

Track info uses plural labels for both the artists and their sort name
when the track has more than one artist, and singular labels otherwise.
Added and last played sit together in album info.

Playlists gain an Info dialog with owner, sharing, song count, length,
creation and update dates. Owner and the public flag are synced from
getPlaylists, so a schema migration adds both columns.
2026-10-21 13:54:37 +00:00
austinried 8052e1db76 ui: sit the filter dot on the top bar's centre line
Place the dot's ring so its centre lands on the top bar (8dp into the
28dp glyph box), with the dot's outer edge kept at the bar's end.
2026-10-21 10:14:10 +00:00
austinried edc8782302 ui: round out the floating indicators
Size the offline button to 48dp and lift the cloud-off glyph by its
own off-centre so it sits in the middle of the circle. Give the active
filter dot the old FAB treatment: a 7dp dot with a ring in the button
colour, moved onto the top bar of the filter glyph.
2026-10-21 10:14:10 +00:00
austinried 3ffe38d988 ui: float the offline indicator opposite the library FAB
Drop the strip above the tab bar, which kept the bottom of the library
busy, and go back to a floating tonal cloud-off button at the bottom
start, level with the FAB on the other side. Tapping it still leaves
offline mode, and the tab bar goes back to just tabs and the gear.
2026-10-21 10:14:10 +00:00
austinried b76328fc26 ui: hold the offline strip to the 48dp touch target
Keep the strip's own tint and the clickable row, but give it the
standard minimum interactive height now that the Surface is no longer
padding it invisibly.
2026-10-21 10:14:10 +00:00
austinried 9feaf54cd2 ui: stop the offline strip reserving a 48dp touch target
The clickable Surface enforced Material's minimum interactive size, so
trimming its row only moved the text inside a band that stayed 48dp
tall and kept the gap to the tabs. Make the strip itself clickable
instead, so it sizes from its padding, and pin it at 36dp.
2026-10-21 10:14:10 +00:00
austinried 72e1b61bff ui: tint the offline strip and make it slimmer
Drop the divider and move the strip onto a lightly primary-tinted
version of the bar colour, so it reads as its own band without a rule.
Trim the vertical padding, leaving the strip at 28dp.
2026-10-21 10:14:10 +00:00
austinried bb3abe39b6 ui: tighten the offline strip and separate it from the tabs
Drop the strip's fixed 44dp height so it wraps its content, and draw a
divider between the strip and the tab bar. The label now sits 6dp above
the rule instead of 12dp above the tab icons, which read as one gap.
2026-10-21 10:14:10 +00:00
austinried be08e50314 ui: keep a gap before the first library tab
Put an 8dp spacer at the start of the scrollable content and let the
reveal leave that gap when scrolling back towards the first tab, so the
selected pill no longer sits flush against the screen edge. The spacer
scrolls away, so a tab that is half off is still cut at the edge.
2026-10-21 10:14:10 +00:00
austinried b9a3ae5ed0 ui: tighten the library tab bar and simplify the offline strip
Drop the strip's divider and shorten its label to "Offline mode". Take
the bar's horizontal inset off the scroll container so a tab that is
scrolled half off is cut at the screen edge instead of stopping inside
the bar's gutter; the gear keeps its end padding.
2026-10-21 10:14:10 +00:00
austinried 47787182d0 ui: scroll the library tab strip and explain offline mode
The tab bar measured the selected tab's full label before the trailing
controls, so a long translation or a large font scale squeezed the
settings gear and eventually removed it, and an indicator in the bar
made that happen sooner. Put the tabs in a weighted, horizontally
scrollable strip and keep the gear outside it, then scroll the selected
tab into view when the pager settles, in either layout direction.

Move the offline indicator out of the bar into a strip above the tabs
that names the mode and offers to go online. Offline mode limits every
list, and Home already swaps to downloaded content without saying why;
an icon in the bar could not explain that and competed for the same
width.
2026-10-21 10:14:10 +00:00
austinried aca2bd4be2 library: filter the artist cover fallback before ranking 2026-10-21 10:14:01 +00:00
austinried 677596c21b library: rank artist cover fallback like the materialized list
Reuse the materialized ranking CTE chain for the per-artist fallback so
duplicate covers keep the same album, exercise the four-row cap, and
test every credit path plus source isolation. Resolve the art input as a
pure function and key the artist and covers off the same source.
2026-10-21 10:14:01 +00:00
austinried 1dbfb027fe library: resolve artist art from live credits
Artist detail only used the materialized artist cover list, so credited
track artists without an artists row, or with an empty or stale cover
list, always fell back to the letter placeholder even though their
appearances albums had art. Observe the credit-derived cover query and
use it for the detail art, preferring the cached downloaded covers when
offline. The query covers album, album_artists, song and song_artists
credits.
2026-10-21 10:14:01 +00:00
austinried 8765c5d37b ui: keep the detail background measurements across navigation 2026-10-21 10:13:52 +00:00
austinried 2da0d61d4c library: bound the detail scroll restore and cover grids
Gate the restore on the caller's content being ready instead of an item
count that can include only the header, clear it after clamping to the
available rows, and include offset-only positions. Apply the same saver
to the artist grid so a process-death restore does not open at the top.
2026-10-21 10:13:52 +00:00
austinried 2c6af4b126 library: keep detail scroll positions across navigation 2026-10-21 10:13:52 +00:00
austinried 61774a19ca library: restore detail scroll positions after the rows load
The lazy list restores its saved position before a paging-backed playlist
has replayed its cached pages, so a zero-item first frame clamped it back
to the top. Track the position separately and reapply it once the item
exists, which covers paged playlists as well as the album and artist
lists. The test disposes and restores the scaffold with an empty first
frame to keep this from regressing.
2026-10-21 10:13:52 +00:00
austinried 0602f835dc ui: list the default sort mode first 2026-10-21 06:42:12 +00:00
austinried 45fc169c42 ui: default to regular names when sorting 2026-10-21 06:36:53 +00:00
austinried e487d2e0b1 library: drop the download progress indicator 2026-10-21 06:04:23 +00:00
austinried 6f1e1612e1 ui: open the artist chooser immediately from the menu 2026-10-21 00:30:41 +00:00
austinried 185aaabbc5 ui: match the download ring to the sync spinner 2026-10-20 23:55:59 +00:00
austinried c1a01b9a47 ui: put a download icon inside the progress ring 2026-10-20 15:00:27 +00:00
austinried fbd4c0c4fe ui: separate info labels from their values 2026-10-20 14:58:52 +00:00
austinried c56a6d4f83 add library info dialogs and sort name preference
Album and artist context menus gain an Info dialog covering the plural
album artists, sort names and the play and download details the screens
do not show. Multiple artist links collapse into one ellipsised entry
that opens the artist chooser instead of listing each artist.

Sort lists can now prefer regular names over sort names, or only use
sort names for names without Latin letters. The new plain-name indexes
keep those orders out of a temp B-tree.

Also carry plural artists through the Media3 queue so Now Playing and
the mini player keep them, drop the album artist tap ripple, and show
the aggregate download progress in the tab bar where offline mode used
to sit.
2026-10-20 14:28:32 +00:00
51 changed files with 6337 additions and 344 deletions
File diff suppressed because it is too large Load Diff
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 },
+68 -14
View File
@@ -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) }
}
}
+24
View File
@@ -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) {