mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
Compare commits
42
Commits
916da72339
...
d038a33b36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d038a33b36 | ||
|
|
fb1c2a02ff | ||
|
|
d81efe168e | ||
|
|
0bd8f1bda8 | ||
|
|
14ed037996 | ||
|
|
e32df2fb08 | ||
|
|
43f0721d26 | ||
|
|
3f1d9b0d8d | ||
|
|
8348e048b5 | ||
|
|
77190a2af1 | ||
|
|
028685d62b | ||
|
|
cac1f6af24 | ||
|
|
98398ab2fe | ||
|
|
b379a22517 | ||
|
|
d4ba9c0faf | ||
|
|
889bdb452b | ||
|
|
194787dfcc | ||
|
|
2e54d09acb | ||
|
|
4b3a7d1dfa | ||
|
|
bb71f4889d | ||
|
|
915225f812 | ||
|
|
88e83c2a86 | ||
|
|
de64e8bc69 | ||
|
|
0e90f51cfa | ||
|
|
250d02e7ab | ||
|
|
ab32a7bfbe | ||
|
|
8941eb6702 | ||
|
|
63d140e67e | ||
|
|
5758609c25 | ||
|
|
b8b5594d04 | ||
|
|
4f6a292e74 | ||
|
|
5dd6e8dab9 | ||
|
|
733a4d2941 | ||
|
|
3198a94df3 | ||
|
|
8cdbe64942 | ||
|
|
824579c938 | ||
|
|
0ac67a124d | ||
|
|
ce33a2b6fd | ||
|
|
d908b5b26d | ||
|
|
d03dd982b2 | ||
|
|
65d74faa1d | ||
|
|
e0cfba489e |
+1900
File diff suppressed because it is too large
Load Diff
+1943
File diff suppressed because it is too large
Load Diff
@@ -15,31 +15,13 @@ import kotlinx.coroutines.flow.Flow
|
||||
// downloads are visible even though they lead no albums of their own.
|
||||
// Starts from songs so status totals include every song the artist is credited on.
|
||||
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId"
|
||||
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
|
||||
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
|
||||
"AND song_downloads.songId = credits.songId"
|
||||
|
||||
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
||||
// Kept hand-written for that plan.
|
||||
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DiscKey
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -69,6 +70,60 @@ 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"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
|
||||
// May repeat a (artistId, songId) pair when several credit paths reach it; consumers dedupe.
|
||||
internal const val CREDITED_SONG_ROWS =
|
||||
"SELECT songs.artistId AS artistId, songs.id AS songId, songs.albumId AS albumId, " +
|
||||
"songs.playCount AS playCount, songs.played AS played " +
|
||||
"FROM songs WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"WHERE song_artists.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT albums.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||
"FROM album_artists JOIN songs " +
|
||||
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId"
|
||||
|
||||
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
||||
internal const val CREDITED_ALBUM_ROWS =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId 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 " +
|
||||
"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 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"
|
||||
|
||||
internal const val ALBUMS_FOR_ARTIST =
|
||||
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||
|
||||
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||
|
||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
||||
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"SELECT albums.id FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
|
||||
@@ -84,18 +139,17 @@ private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
|
||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL " +
|
||||
"AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId 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 " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
|
||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
"AND sd.status = 'Completed'"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
@@ -110,6 +164,41 @@ internal const val ARTISTS_FILTER =
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ARTIST_ALBUM_CREDIT_ART =
|
||||
"SELECT albums.coverArt FROM (" +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
|
||||
"INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
|
||||
"UNION ALL " +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
|
||||
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
|
||||
|
||||
internal const val ARTIST_TRACK_CREDIT_ART =
|
||||
"SELECT albums.coverArt FROM (" +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
|
||||
"UNION ALL " +
|
||||
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" +
|
||||
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
|
||||
|
||||
internal const val ARTIST_COVER_ART =
|
||||
"COALESCE(artists.coverArt, CASE " +
|
||||
"WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists " +
|
||||
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " +
|
||||
"THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)"
|
||||
|
||||
internal const val ARTIST_COLUMNS =
|
||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
|
||||
"artists.sortName, artists.credited"
|
||||
|
||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||
// an artist matches the albums its detail screen lists.
|
||||
internal const val ARTISTS_SELECT =
|
||||
@@ -117,13 +206,13 @@ internal const val ARTISTS_SELECT =
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
|
||||
"artists.credited "
|
||||
"artists.starred, $ARTIST_COVER_ART AS coverArt, 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 = "artists.albumCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, 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"
|
||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
|
||||
@@ -211,62 +300,44 @@ interface LibraryDao {
|
||||
)
|
||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||
|
||||
// The credited albums are the union of the lead id and the relation table, so a shared album
|
||||
// counts once per artist rather than twice.
|
||||
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
||||
// queries are separate because their visibility follows the credit path.
|
||||
@Query(
|
||||
"WITH credited AS (" +
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, " +
|
||||
"albums.playCount AS playCount, albums.played AS played " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId" +
|
||||
"), totals AS (" +
|
||||
"SELECT artistId AS id, SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||
"totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited GROUP BY artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET playCount = c.playCount, played = c.played " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.playCount != c.playCount OR artists.played IS NOT c.played)",
|
||||
)
|
||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
||||
|
||||
// Stored so the download badge query can report an artist's total without walking every song.
|
||||
@Query(
|
||||
"WITH credited AS (" +
|
||||
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
|
||||
"FROM album_artists " +
|
||||
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId" +
|
||||
"), counts AS (" +
|
||||
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||
"FROM artists LEFT JOIN counts ON counts.id = artists.id " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
|
||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||
"OR artists.credited != c.credited)",
|
||||
)
|
||||
suspend fun recomputeArtistSongCounts(sourceId: Long)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun recomputePlayData(sourceId: Long) {
|
||||
suspend fun recomputeAggregates(sourceId: Long) {
|
||||
recomputeAlbumPlayData(sourceId)
|
||||
recomputeArtistPlayData(sourceId)
|
||||
recomputeArtistAggregates(sourceId)
|
||||
}
|
||||
|
||||
@Query(
|
||||
@@ -293,11 +364,14 @@ interface LibraryDao {
|
||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
||||
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId))",
|
||||
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId) " +
|
||||
"OR id = (SELECT artistId FROM songs WHERE sourceId = :sourceId AND id = :songId) " +
|
||||
"OR id IN (SELECT artistId FROM song_artists WHERE sourceId = :sourceId AND songId = :songId))",
|
||||
)
|
||||
suspend fun bumpArtistPlay(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
albumId: String?,
|
||||
songId: String,
|
||||
at: Long,
|
||||
)
|
||||
|
||||
@@ -316,9 +390,9 @@ interface LibraryDao {
|
||||
at: Long,
|
||||
) {
|
||||
bumpSongPlay(sourceId, songId, at)
|
||||
val albumId = albumIdForSong(sourceId, songId) ?: return
|
||||
bumpAlbumPlay(sourceId, albumId, at)
|
||||
bumpArtistPlay(sourceId, albumId, at)
|
||||
val albumId = albumIdForSong(sourceId, songId)
|
||||
if (albumId != null) bumpAlbumPlay(sourceId, albumId, at)
|
||||
bumpArtistPlay(sourceId, albumId, songId, at)
|
||||
}
|
||||
|
||||
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
||||
@@ -339,7 +413,7 @@ interface LibraryDao {
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun recentlyPlayedArtists(
|
||||
@@ -357,7 +431,7 @@ interface LibraryDao {
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun mostPlayedArtists(
|
||||
@@ -406,7 +480,7 @@ interface LibraryDao {
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
)
|
||||
fun searchArtists(
|
||||
@@ -568,6 +642,8 @@ interface LibraryDao {
|
||||
@RawQuery(observedEntities = [Album::class])
|
||||
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||
|
||||
// The fallback art reads albums and credits, but a successful sync writes sources after the
|
||||
// library changes, so observing artists and sources avoids invalidating on every song/album write.
|
||||
@RawQuery(observedEntities = [Artist::class, Source::class])
|
||||
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||
|
||||
@@ -818,13 +894,13 @@ interface LibraryDao {
|
||||
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||
@@ -883,7 +959,7 @@ interface LibraryDao {
|
||||
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
|
||||
@Query(
|
||||
"SELECT artists.* FROM artists " +
|
||||
"SELECT $ARTIST_COLUMNS FROM artists " +
|
||||
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
||||
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
||||
@@ -982,18 +1058,33 @@ interface LibraryDao {
|
||||
albumId: String,
|
||||
): Album?
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||
suspend fun artistOnce(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Artist?
|
||||
|
||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||
fun artist(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<Artist?>
|
||||
|
||||
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||
fun artistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<ArtistBio?>
|
||||
|
||||
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||
suspend fun artistBioOnce(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): ArtistBio?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertArtistBio(item: ArtistBio)
|
||||
|
||||
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
||||
@Query(
|
||||
"SELECT COALESCE(" +
|
||||
@@ -1025,29 +1116,18 @@ interface LibraryDao {
|
||||
songId: String,
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM (" +
|
||||
"SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* 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 " +
|
||||
"UNION " +
|
||||
"SELECT albums.* 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 " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
@Query(ALBUMS_FOR_ARTIST)
|
||||
fun albumsForArtist(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
||||
fun albumCreditAlbumIds(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||
fun discs(
|
||||
sourceId: Long,
|
||||
@@ -1083,6 +1163,7 @@ interface LibraryDao {
|
||||
playlistId: String,
|
||||
): PagingSource<Int, PlaylistSongItem>
|
||||
|
||||
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
||||
@Query(
|
||||
"SELECT * FROM (" +
|
||||
"SELECT albums.* FROM albums " +
|
||||
|
||||
@@ -90,7 +90,7 @@ internal fun artistsDefaultQuery(
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
stmt.bindInt(3, starredFilter)
|
||||
|
||||
@@ -579,6 +579,90 @@ val MIGRATION_32_33 =
|
||||
}
|
||||
}
|
||||
|
||||
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
|
||||
// page and the shuffle queue agree for libraries that predate the change.
|
||||
val MIGRATION_33_34 =
|
||||
object : Migration(33, 34) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"WITH credited_songs AS (" +
|
||||
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
|
||||
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
|
||||
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
|
||||
"songs.`playCount`, songs.`played` " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||
"UNION " +
|
||||
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
|
||||
"songs.`playCount`, songs.`played` " +
|
||||
"FROM albums JOIN songs " +
|
||||
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
|
||||
"WHERE albums.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
|
||||
"songs.`playCount`, songs.`played` " +
|
||||
"FROM album_artists JOIN songs " +
|
||||
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
|
||||
"), credited_albums AS (" +
|
||||
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
|
||||
"albums.`id` AS albumId " +
|
||||
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
|
||||
"UNION " +
|
||||
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
|
||||
"FROM songs JOIN albums " +
|
||||
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
|
||||
"WHERE songs.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
|
||||
"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`" +
|
||||
"), totals AS (" +
|
||||
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited_songs GROUP BY sourceId, artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY sourceId, artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played " +
|
||||
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
|
||||
"COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||
"FROM artists LEFT JOIN totals " +
|
||||
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
|
||||
"LEFT JOIN album_totals " +
|
||||
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
|
||||
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
|
||||
") c " +
|
||||
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
|
||||
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
|
||||
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_34_35 =
|
||||
object : Migration(34, 35) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
|
||||
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
|
||||
"PRIMARY KEY(`sourceId`, `artistId`), " +
|
||||
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||
private suspend fun createSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
@@ -645,4 +729,6 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_30_31,
|
||||
MIGRATION_31_32,
|
||||
MIGRATION_32_33,
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
)
|
||||
|
||||
@@ -20,17 +20,7 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||
|
||||
private const val ARTIST_SONG_IDS =
|
||||
"(SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT songId FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT songs.id FROM albums " +
|
||||
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT songs.id FROM album_artists " +
|
||||
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId)"
|
||||
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||
|
||||
private const val ARTIST_SONGS_SELECT =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.AlbumSearch
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.ArtistRefsConverter
|
||||
import com.subtracks.data.model.ArtistSearch
|
||||
import com.subtracks.data.model.ArtworkSeed
|
||||
@@ -35,6 +36,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
Source::class,
|
||||
SubsonicSource::class,
|
||||
Artist::class,
|
||||
ArtistBio::class,
|
||||
ArtistSearch::class,
|
||||
Album::class,
|
||||
AlbumSearch::class,
|
||||
@@ -54,7 +56,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 33,
|
||||
version = 35,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
|
||||
@@ -95,6 +95,24 @@ data class Artist(
|
||||
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artist_bios",
|
||||
primaryKeys = ["sourceId", "artistId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Source::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sourceId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class ArtistBio(
|
||||
val sourceId: Long,
|
||||
val artistId: String,
|
||||
val biography: String?,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "albums",
|
||||
primaryKeys = ["sourceId", "id"],
|
||||
@@ -167,6 +185,13 @@ data class Album(
|
||||
}
|
||||
}
|
||||
|
||||
data class ArtistAlbums(
|
||||
val albumCredits: List<Album> = emptyList(),
|
||||
val appearances: List<Album> = emptyList(),
|
||||
) {
|
||||
val count: Int get() = albumCredits.size + appearances.size
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "discs",
|
||||
primaryKeys = ["sourceId", "albumId", "disc"],
|
||||
|
||||
@@ -57,7 +57,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
||||
|
||||
enum class SyncMode { Off, WifiOnly, Always }
|
||||
|
||||
enum class ReplayGainMode { Off, Track, Album }
|
||||
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||
|
||||
data class ArtworkSeedValue(
|
||||
val cacheKey: String,
|
||||
@@ -154,7 +154,7 @@ class UserPreferences(
|
||||
|
||||
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
|
||||
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Auto
|
||||
}
|
||||
|
||||
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
|
||||
import com.subtracks.data.download.EngineRequest
|
||||
import com.subtracks.data.download.EngineStatus
|
||||
import com.subtracks.data.download.looksLikeAudio
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadError
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -43,6 +44,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class DownloadRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
@@ -63,6 +65,7 @@ class DownloadRepository(
|
||||
private var started = false
|
||||
|
||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
@@ -406,12 +409,15 @@ class DownloadRepository(
|
||||
private suspend fun fetchArtwork(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
artistIds: MutableSet<String>,
|
||||
) {
|
||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||
val coverArt =
|
||||
(
|
||||
listOfNotNull(
|
||||
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
|
||||
album?.coverArt,
|
||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
).distinct()
|
||||
@@ -421,6 +427,36 @@ class DownloadRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
) {
|
||||
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
|
||||
val key = sourceId to artistId
|
||||
if (!attemptedBios.add(key)) return
|
||||
try {
|
||||
val biography = sourceRepository.artistBiography(sourceId, artistId)
|
||||
db.libraryDao().upsertArtistBio(
|
||||
ArtistBio(sourceId, artistId, normalizeBiography(biography)),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
attemptedBios.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBiosSafely(
|
||||
sourceId: Long,
|
||||
artistIds: Set<String>,
|
||||
) {
|
||||
if (artistIds.isEmpty()) return
|
||||
withTimeoutOrNull(BIO_TIMEOUT_MS) {
|
||||
artistIds.forEach { fetchArtistBio(sourceId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeArtwork(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
@@ -537,11 +573,12 @@ class DownloadRepository(
|
||||
sourceId: Long,
|
||||
songIds: List<String>,
|
||||
) {
|
||||
val artistIds = mutableSetOf<String>()
|
||||
try {
|
||||
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
||||
songIds.forEach { songId ->
|
||||
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
||||
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId)
|
||||
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId, artistIds)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -549,6 +586,7 @@ class DownloadRepository(
|
||||
} catch (_: Exception) {
|
||||
// Artwork is best effort and must never disturb a download that is already running.
|
||||
}
|
||||
fetchArtistBiosSafely(sourceId, artistIds)
|
||||
}
|
||||
|
||||
private suspend fun apply(
|
||||
@@ -673,5 +711,6 @@ class DownloadRepository(
|
||||
const val IN_FLIGHT_LIMIT = 8
|
||||
const val DELETE_CHUNK = 900
|
||||
const val ART_TIMEOUT_MS = 60_000L
|
||||
const val BIO_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
@@ -31,7 +33,9 @@ import kotlinx.coroutines.Dispatchers
|
||||
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.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -354,6 +358,36 @@ class LibraryRepository(
|
||||
artistId: String,
|
||||
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
||||
|
||||
fun artistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<ArtistBio?> =
|
||||
db
|
||||
.libraryDao()
|
||||
.artistBio(sourceId, artistId)
|
||||
.map { bio -> bio?.copy(biography = normalizeBiography(bio.biography)) }
|
||||
|
||||
suspend fun refreshArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Result<Unit> =
|
||||
try {
|
||||
val fetched = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
|
||||
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||
// An empty response can mean the metadata provider is temporarily down, so keep the
|
||||
// cached biography instead of erasing it.
|
||||
val biography = fetched ?: current?.biography
|
||||
if (current == null || current.biography != biography) {
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||
Result.failure(failure)
|
||||
}
|
||||
|
||||
fun song(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
@@ -376,12 +410,19 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
downloaded: Boolean = false,
|
||||
): Flow<List<Album>> =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||
} else {
|
||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
): Flow<ArtistAlbums> {
|
||||
val albums =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||
} else {
|
||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
}
|
||||
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
|
||||
val credits = creditIds.toHashSet()
|
||||
val (albumCredits, appearances) = list.partition { it.id in credits }
|
||||
ArtistAlbums(albumCredits = albumCredits, appearances = appearances)
|
||||
}
|
||||
}
|
||||
|
||||
fun albumDiscs(
|
||||
sourceId: Long,
|
||||
@@ -520,4 +561,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
||||
|
||||
const val SEARCH_RESULT_LIMIT = 50
|
||||
|
||||
internal fun normalizeBiography(value: String?): String? =
|
||||
value
|
||||
?.replace("\r\n", "\n")
|
||||
?.replace('\r', '\n')
|
||||
?.replace(Regex(" {3,}"), "\n\n")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||
|
||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
||||
}
|
||||
|
||||
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
||||
|
||||
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
|
||||
}
|
||||
|
||||
private fun entryIndexAfter(
|
||||
@@ -295,8 +297,8 @@ class QueueRepository(
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
||||
// label it by its album rather than losing the context name.
|
||||
// A song entry can be a single or a track reordered out of a playlist, artist or
|
||||
// genre, so label it by its album rather than losing the context name.
|
||||
QueueKind.Song -> {
|
||||
library
|
||||
.song(sourceId, refId)
|
||||
@@ -581,9 +583,9 @@ class QueueRepository(
|
||||
val toUpNext = snapshot.isUpNext(to)
|
||||
if (fromUpNext && toUpNext) {
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
|
||||
val located = snapshot.locate(from) ?: return false
|
||||
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||
return true
|
||||
}
|
||||
@@ -597,7 +599,7 @@ class QueueRepository(
|
||||
val located = snapshot.locateContext(playFrom) ?: return false
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val removed = removeEntry(snapshot.entries, playFrom)
|
||||
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||
adjustAnchorOnMove(playFrom, insertAt)
|
||||
@@ -929,6 +931,18 @@ class QueueRepository(
|
||||
return count?.coerceAtMost(available) ?: available
|
||||
}
|
||||
|
||||
private fun movedEntry(
|
||||
source: QueueEntry,
|
||||
songId: String,
|
||||
offsetInEntry: Long,
|
||||
): QueueEntry =
|
||||
if (source.kind == QueueKind.Album) {
|
||||
val offset = source.offset + offsetInEntry
|
||||
source.range(offset, offset)
|
||||
} else {
|
||||
songEntry(source.sourceId, songId)
|
||||
}
|
||||
|
||||
private fun QueueEntry.range(
|
||||
start: Long,
|
||||
end: Long,
|
||||
|
||||
@@ -261,6 +261,15 @@ class SourceRepository(
|
||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||
}
|
||||
|
||||
suspend fun artistBiography(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String? {
|
||||
check(!_offline.value) { "Offline mode is on" }
|
||||
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
|
||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
|
||||
}
|
||||
|
||||
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||
client(
|
||||
address = address,
|
||||
|
||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
||||
time: Long? = null,
|
||||
)
|
||||
|
||||
suspend fun artistBiography(artistId: String): String? = null
|
||||
|
||||
fun artists(): Flow<List<Artist>>
|
||||
|
||||
fun albums(): Flow<List<Album>>
|
||||
|
||||
@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
open class SubsonicException(
|
||||
val code: Int,
|
||||
@@ -55,11 +56,12 @@ class SubsonicClient(
|
||||
fun <T> stream(
|
||||
method: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
timeoutMs: Long? = null,
|
||||
body: (InputStream) -> T,
|
||||
): T {
|
||||
val usedTokenAuth = useTokenAuth
|
||||
return try {
|
||||
execute(method, params, body)
|
||||
execute(method, params, timeoutMs, body)
|
||||
} catch (failure: SubsonicException) {
|
||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||
@@ -67,13 +69,14 @@ class SubsonicClient(
|
||||
}
|
||||
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||
disableTokenAuth()
|
||||
execute(method, params, body)
|
||||
execute(method, params, timeoutMs, body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> execute(
|
||||
method: String,
|
||||
params: Map<String, String>,
|
||||
timeoutMs: Long?,
|
||||
body: (InputStream) -> T,
|
||||
): T {
|
||||
val request =
|
||||
@@ -81,7 +84,9 @@ class SubsonicClient(
|
||||
.Builder()
|
||||
.url(uri(method, params))
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
val call = http.newCall(request)
|
||||
timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
|
||||
call.execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
||||
}
|
||||
|
||||
@@ -85,6 +85,13 @@ class SubsonicSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun artistBiography(artistId: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
client.stream("getArtistInfo2", mapOf("id" to artistId), timeoutMs = ARTIST_BIO_TIMEOUT_MS) { input ->
|
||||
SubsonicXml.artistBiography(input)
|
||||
}
|
||||
}
|
||||
|
||||
override fun artists(): Flow<List<Artist>> =
|
||||
entityBatches(
|
||||
method = "getArtists",
|
||||
@@ -365,6 +372,7 @@ class SubsonicSource(
|
||||
const val PAGE_SIZE = 500
|
||||
const val MAX_PAGES = 20_000
|
||||
const val THUMBNAIL_SIZE = 384
|
||||
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,12 @@ object SubsonicXml {
|
||||
parse(input, handler)
|
||||
return handler.found
|
||||
}
|
||||
|
||||
internal fun artistBiography(input: InputStream): String? {
|
||||
val handler = ArtistBiographyHandler()
|
||||
parse(input, handler)
|
||||
return handler.biography
|
||||
}
|
||||
}
|
||||
|
||||
internal class AlbumDraft(
|
||||
@@ -421,6 +427,40 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private class ArtistBiographyHandler : SubsonicResponseHandler() {
|
||||
private var biographyDepth = -1
|
||||
private val text = StringBuilder()
|
||||
|
||||
var biography: String? = null
|
||||
private set
|
||||
|
||||
override fun characters(
|
||||
ch: CharArray,
|
||||
start: Int,
|
||||
length: Int,
|
||||
) {
|
||||
if (biographyDepth >= 0) text.append(ch, start, length)
|
||||
}
|
||||
|
||||
override fun onElementStart(
|
||||
name: String,
|
||||
attributes: Attributes,
|
||||
depth: Int,
|
||||
) {
|
||||
if (name == "biography" && biographyDepth < 0) biographyDepth = depth
|
||||
}
|
||||
|
||||
override fun onElementEnd(
|
||||
name: String,
|
||||
depth: Int,
|
||||
) {
|
||||
if (name == "biography" && depth == biographyDepth) {
|
||||
biography = text.toString().trim().takeIf { it.isNotEmpty() }
|
||||
biographyDepth = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EntityHandler<T>(
|
||||
private val entityTag: String,
|
||||
private val create: (Attributes) -> T,
|
||||
|
||||
@@ -31,23 +31,17 @@ class SyncService(
|
||||
private var playlistSongsSynced = 0
|
||||
private var pruned = 0
|
||||
|
||||
private var creditsChanged = false
|
||||
|
||||
suspend fun sync(): SyncSummary {
|
||||
val sources = db.sourcesDao()
|
||||
val wasStale = sources.creditsStale(source.id) ?: true
|
||||
creditsChanged = false
|
||||
val artistsBefore = dbTotalChanges()
|
||||
syncArtists()
|
||||
val artistsChanged = dbTotalChanges() != artistsBefore
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
val artistsChanged = syncArtists()
|
||||
val albumsChanged = syncAlbums()
|
||||
val songsChanged = syncSongs()
|
||||
optimize()
|
||||
if (creditsChanged || artistsChanged || wasStale) {
|
||||
recomputeArtistSongCounts()
|
||||
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
|
||||
recomputeAggregates()
|
||||
sources.setCreditsStale(source.id, false)
|
||||
}
|
||||
aggregatePlayData()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
return SyncSummary(
|
||||
@@ -60,29 +54,35 @@ class SyncService(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun syncArtists() {
|
||||
private suspend fun syncArtists(): Boolean {
|
||||
val library = db.libraryDao()
|
||||
val seen = HashedIds()
|
||||
var changed = false
|
||||
source.artists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||
changed =
|
||||
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
||||
changed
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
artistsSynced += batch.size
|
||||
}
|
||||
val prunedBefore = pruned
|
||||
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||
return changed || pruned > prunedBefore
|
||||
}
|
||||
|
||||
private suspend fun syncAlbums() {
|
||||
private suspend fun syncAlbums(): Boolean {
|
||||
val library = db.libraryDao()
|
||||
val seenAlbums = HashedIds()
|
||||
val seenDiscs = HashedIds()
|
||||
var changed = false
|
||||
source.albums().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
val discs =
|
||||
batch.flatMap { album ->
|
||||
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
||||
}
|
||||
writeAlbumsAndDiscs(batch, discs)
|
||||
changed = writeAlbumsAndDiscs(batch, discs) || changed
|
||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||
albumsSynced += batch.size
|
||||
@@ -95,19 +95,20 @@ class SyncService(
|
||||
{ library.deleteAlbums(source.id, it) },
|
||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||
)
|
||||
if (pruned > prunedBefore) creditsChanged = true
|
||||
pruned +=
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
return changed || pruned > prunedBefore
|
||||
}
|
||||
|
||||
private suspend fun syncSongs() {
|
||||
private suspend fun syncSongs(): Boolean {
|
||||
val library = db.libraryDao()
|
||||
val seen = HashedIds()
|
||||
var changed = false
|
||||
source.songs().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
writeSongs(batch)
|
||||
changed = writeSongs(batch) || changed
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
songsSynced += batch.size
|
||||
}
|
||||
@@ -119,10 +120,10 @@ class SyncService(
|
||||
{ library.deleteSongs(source.id, it) },
|
||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||
)
|
||||
if (pruned > prunedBefore) creditsChanged = true
|
||||
return changed || pruned > prunedBefore
|
||||
}
|
||||
|
||||
private suspend fun writeSongs(batch: List<Song>) {
|
||||
private suspend fun writeSongs(batch: List<Song>): Boolean {
|
||||
val names = batch.associate { it.id to it.genreNames() }
|
||||
val genres =
|
||||
batch.flatMap { song ->
|
||||
@@ -132,10 +133,9 @@ class SyncService(
|
||||
batch.flatMap { song ->
|
||||
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
return db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
val albumChanged = connection.albumIdsChanged(batch)
|
||||
if (albumChanged) creditsChanged = true
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveSongSortNames(batch)
|
||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
@@ -149,7 +149,6 @@ class SyncService(
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||
val artistRowsBefore = connection.totalChanges()
|
||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -161,21 +160,15 @@ class SyncService(
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
||||
val artistRowsChanged = connection.totalChanges() != artistRowsBefore
|
||||
if (albumChanged || artistRowsChanged) {
|
||||
connection.markCreditsStale(batch.first().sourceId)
|
||||
creditsChanged = true
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun aggregatePlayData() {
|
||||
db.libraryDao().recomputePlayData(source.id)
|
||||
}
|
||||
|
||||
private suspend fun recomputeArtistSongCounts() {
|
||||
db.libraryDao().recomputeArtistSongCounts(source.id)
|
||||
private suspend fun recomputeAggregates() {
|
||||
db.libraryDao().recomputeAggregates(source.id)
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.totalChanges(): Long {
|
||||
@@ -187,8 +180,6 @@ class SyncService(
|
||||
return value
|
||||
}
|
||||
|
||||
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
|
||||
|
||||
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
@@ -196,28 +187,6 @@ class SyncService(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
|
||||
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
|
||||
if (expected.isEmpty()) return false
|
||||
var changed = false
|
||||
batch
|
||||
.map { it.sourceId to it.id }
|
||||
.chunked(SQL_VARIABLES)
|
||||
.forEach { chunk ->
|
||||
val placeholders = chunk.joinToString(",") { "?" }
|
||||
usePrepared("SELECT id, albumId FROM songs WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||
statement.bindLong(1, chunk.first().first)
|
||||
chunk.forEachIndexed { index, (_, id) -> statement.bindText(index + 2, id) }
|
||||
while (statement.step()) {
|
||||
val id = statement.getText(0)
|
||||
val albumId = if (statement.isNull(1)) null else statement.getText(1)
|
||||
if (expected.remove(id) != albumId) changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed || expected.isNotEmpty()
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||
if (batch.isEmpty()) return batch
|
||||
val sortNames =
|
||||
@@ -314,29 +283,34 @@ class SyncService(
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
markStale: Boolean = false,
|
||||
values: (T) -> List<Any?>,
|
||||
) {
|
||||
): Boolean =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged(table, columns, key, rows, values)
|
||||
val before = connection.totalChanges()
|
||||
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeAlbumsAndDiscs(
|
||||
albums: List<Album>,
|
||||
discs: List<Disc>,
|
||||
) {
|
||||
): Boolean {
|
||||
val artists =
|
||||
albums.flatMap { album ->
|
||||
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
return db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveAlbumSortNames(albums)
|
||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||
val artistRowsBefore = connection.totalChanges()
|
||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||
albums.forEach { album ->
|
||||
statement.bind(1, album.sourceId)
|
||||
@@ -348,9 +322,8 @@ class SyncService(
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
||||
if (connection.totalChanges() != artistRowsBefore) {
|
||||
connection.markCreditsStale(albums.first().sourceId)
|
||||
creditsChanged = true
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,11 +586,12 @@ private suspend fun <T> PooledConnection.upsertChanged(
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
values: (T) -> List<Any?>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||
val updates = columns.filterNot { it in key }
|
||||
val updates = columns.filterNot { it in key || it in preserved }
|
||||
val sql =
|
||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||
|
||||
@@ -244,6 +244,7 @@ private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
||||
|
||||
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||
@@ -255,6 +256,8 @@ internal fun MediaItem?.replayGain(): ReplayGain {
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
|
||||
|
||||
private fun Bundle.putGain(
|
||||
key: String,
|
||||
value: Float?,
|
||||
@@ -288,6 +291,7 @@ internal fun queueMediaItem(
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
||||
},
|
||||
).build()
|
||||
val builder =
|
||||
|
||||
@@ -35,6 +35,7 @@ data class QueueItem(
|
||||
val coverArtId: String?,
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
val albumContext: Boolean = false,
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
@@ -393,7 +394,7 @@ class PlaybackController(
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val playing = player.playWhenReady
|
||||
val wasUpNext = snapshot.isUpNext(current)
|
||||
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
|
||||
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
||||
queueRepository.removeAt(snapshot, position)
|
||||
var updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
@@ -545,13 +546,13 @@ class PlaybackController(
|
||||
return@withLock
|
||||
}
|
||||
when {
|
||||
fromInWindow && toInWindow -> {
|
||||
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||
updating = true
|
||||
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
||||
updating = false
|
||||
}
|
||||
|
||||
!fromInWindow && !toInWindow -> {
|
||||
!fromInWindow && !toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||
// The moved track never enters the window, so the playlist is unchanged; only
|
||||
// the window's positions shift, and a move that spans it shifts them by one.
|
||||
when {
|
||||
@@ -944,13 +945,13 @@ class PlaybackController(
|
||||
if (!isOffline) {
|
||||
return nextPosition(snapshot, at)
|
||||
?.takeIf { it != at }
|
||||
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrAfter(snapshot, at + 1)
|
||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
||||
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
|
||||
suspend fun previousItem(): QueueItem? {
|
||||
@@ -967,13 +968,13 @@ class PlaybackController(
|
||||
if (!isOffline) {
|
||||
return previousPosition(snapshot, at)
|
||||
?.takeIf { it != at }
|
||||
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrBefore(snapshot, at - 1)
|
||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
|
||||
private fun play(
|
||||
@@ -1132,7 +1133,7 @@ class PlaybackController(
|
||||
lastPosition = actual
|
||||
if (actual != requested) queueRepository.setCursor(actual)
|
||||
updating = true
|
||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
|
||||
updating = false
|
||||
if (autoplay) {
|
||||
player.prepare()
|
||||
@@ -1172,7 +1173,7 @@ class PlaybackController(
|
||||
window.forEachIndexed { index, entry ->
|
||||
val local = downloads.localUri(entry.item.song.id) != null
|
||||
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
||||
player.replaceItem(index, entry.item.toQueueItem())
|
||||
player.replaceItem(index, entry.playerItem(snapshot))
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
@@ -1287,8 +1288,8 @@ class PlaybackController(
|
||||
updating = true
|
||||
try {
|
||||
while (windowStart > desiredStart) {
|
||||
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item.toQueueItem())
|
||||
val item = queueItemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item)
|
||||
windowStart--
|
||||
}
|
||||
while (windowStart < desiredStart) {
|
||||
@@ -1296,8 +1297,8 @@ class PlaybackController(
|
||||
windowStart++
|
||||
}
|
||||
while (windowEnd < desiredEnd) {
|
||||
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item.toQueueItem())
|
||||
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item)
|
||||
windowEnd++
|
||||
}
|
||||
while (windowEnd > desiredEnd) {
|
||||
@@ -1315,6 +1316,7 @@ class PlaybackController(
|
||||
position: Long,
|
||||
) {
|
||||
val player = player ?: return
|
||||
val snapshot = this.snapshot ?: return
|
||||
downloads.awaitLoaded()
|
||||
val ordered =
|
||||
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
||||
@@ -1328,7 +1330,7 @@ class PlaybackController(
|
||||
val current = ordered[index]
|
||||
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
||||
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
||||
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem())
|
||||
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
|
||||
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
||||
windowFiltered = offline
|
||||
windowStart = ordered.first().position
|
||||
@@ -1338,8 +1340,8 @@ class PlaybackController(
|
||||
try {
|
||||
repeat(player.currentIndex) { player.removeFirst() }
|
||||
while (player.itemCount > 1) player.removeLast()
|
||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
|
||||
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
|
||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
|
||||
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
|
||||
} finally {
|
||||
updating = false
|
||||
}
|
||||
@@ -1481,6 +1483,17 @@ class PlaybackController(
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
|
||||
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||
|
||||
private suspend fun queueItemAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
): QueueItem? =
|
||||
queueRepository
|
||||
.itemAt(snapshot, position)
|
||||
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||
|
||||
private fun contextAt(position: Long?): QueueContext? {
|
||||
val snapshot = snapshot ?: return null
|
||||
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.SongItem
|
||||
|
||||
fun SongItem.toQueueItem() =
|
||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
QueueItem(
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
@@ -11,4 +11,5 @@ fun SongItem.toQueueItem() =
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
replayGain = song.replayGain(),
|
||||
albumContext = albumContext,
|
||||
)
|
||||
|
||||
@@ -29,17 +29,20 @@ internal fun Song.replayGain(): ReplayGain =
|
||||
internal fun replayGainLinearGain(
|
||||
gain: ReplayGain,
|
||||
settings: ReplayGainSettings,
|
||||
albumContext: Boolean = false,
|
||||
): Float {
|
||||
if (settings.mode == ReplayGainMode.Off) return 1f
|
||||
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
|
||||
// headroom without a peak, so a peakless track is only ever attenuated.
|
||||
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||
val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Auto && albumContext)
|
||||
val (requestedDb, peak) =
|
||||
when (settings.mode) {
|
||||
ReplayGainMode.Off -> return 1f
|
||||
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||
if (album) {
|
||||
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||
} else {
|
||||
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||
}
|
||||
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||
val requested = gainDb + settings.preampDb
|
||||
|
||||
@@ -7,11 +7,13 @@ internal class ReplayGainController(
|
||||
private val apply: (Float) -> Unit,
|
||||
) {
|
||||
private var track = ReplayGain()
|
||||
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||
private var albumContext = false
|
||||
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
|
||||
|
||||
@Synchronized
|
||||
fun applyItem(item: MediaItem) {
|
||||
track = item.replayGain()
|
||||
albumContext = item.albumContext()
|
||||
applyGain()
|
||||
}
|
||||
|
||||
@@ -22,6 +24,6 @@ internal class ReplayGainController(
|
||||
}
|
||||
|
||||
private fun applyGain() {
|
||||
apply(replayGainLinearGain(track, settings))
|
||||
apply(replayGainLinearGain(track, settings, albumContext))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -28,7 +27,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
||||
|
||||
@@ -40,7 +38,6 @@ fun ArtistChooserSheet(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -66,13 +63,7 @@ fun ArtistChooserSheet(
|
||||
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
onArtistClick(artist)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ sealed interface MenuTarget {
|
||||
) : MenuTarget {
|
||||
override val title: String get() = song.title
|
||||
|
||||
override val subtitle: String? get() = song.artist
|
||||
override val subtitle: String? get() = song.artistSubtitle()
|
||||
}
|
||||
|
||||
data class Album(
|
||||
|
||||
@@ -20,6 +20,10 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -28,6 +32,8 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
@@ -43,6 +49,12 @@ fun SearchField(
|
||||
) {
|
||||
val hint = stringResource(R.string.search_input_placeholder)
|
||||
val closeLabel = stringResource(R.string.close_search)
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) }
|
||||
LaunchedEffect(value) {
|
||||
if (value != textFieldValue.text) {
|
||||
textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -62,8 +74,11 @@ fun SearchField(
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
onValueChange(it.text)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
@@ -73,7 +88,7 @@ fun SearchField(
|
||||
.focusRequester(focusRequester)
|
||||
.semantics { contentDescription = hint },
|
||||
decorationBox = { inner ->
|
||||
if (value.isEmpty()) {
|
||||
if (textFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.Song
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
internal fun Song.artistSubtitle(): String? {
|
||||
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||
if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() }
|
||||
val primary = artist?.takeIf { it in names }
|
||||
return buildList {
|
||||
primary?.let(::add)
|
||||
addAll(names.filterNot { it == primary })
|
||||
}.joinToString(ARTIST_SEPARATOR)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ fun SongInfoDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artistSubtitle())
|
||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(
|
||||
|
||||
@@ -33,6 +33,8 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.Mic
|
||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||
import androidx.compose.material.icons.rounded.Shuffle
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
@@ -48,6 +50,7 @@ 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
|
||||
@@ -64,22 +67,29 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
@@ -106,10 +116,12 @@ import com.subtracks.ui.theme.heroBarColor
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import kotlin.math.ceil
|
||||
|
||||
private val ART_HEIGHT = 420.dp
|
||||
private val TITLE_INSET = 16.dp
|
||||
@@ -120,6 +132,10 @@ private val SHUFFLE_ICON = 30.dp
|
||||
private val SHUFFLE_END = 22.dp
|
||||
private val SHUFFLE_RESERVE = 86.dp
|
||||
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
||||
private val BIO_ACTION_RESERVE = 104.dp
|
||||
private val BIO_ACTION_HEIGHT = 48.dp
|
||||
private val BIO_ACTION_OFFSET = 8.dp
|
||||
private val BIO_TOP_GAP = 8.dp
|
||||
|
||||
internal fun estimateScrollPx(
|
||||
index: Int,
|
||||
@@ -139,11 +155,14 @@ private const val ARTIST_NAME_MAX_LINES = 6
|
||||
private const val THEME_TRANSITION_MS = 100
|
||||
private const val SCRIM_FADE_MS = 180
|
||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
|
||||
internal const val ARTIST_BIO_LESS_TAG = "artistBioLess"
|
||||
|
||||
internal fun isAlbumArtist(
|
||||
albums: List<Album>,
|
||||
artistId: String,
|
||||
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
|
||||
private data class BioSnapshot(
|
||||
val known: Boolean,
|
||||
val present: Boolean,
|
||||
val biography: String?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ArtistDetailRoute(
|
||||
@@ -160,6 +179,18 @@ fun ArtistDetailRoute(
|
||||
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 bioSnapshots =
|
||||
remember(viewModel) {
|
||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||
}
|
||||
val bioSnapshot by
|
||||
bioSnapshots.collectAsStateWithLifecycle(
|
||||
initialValue = BioSnapshot(known = false, present = false, biography = null),
|
||||
)
|
||||
val bioLoading by viewModel.bioLoading.collectAsStateWithLifecycle()
|
||||
val bioUnavailable by viewModel.bioUnavailable.collectAsStateWithLifecycle()
|
||||
val bioExpanded by viewModel.bioExpanded.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
@@ -192,9 +223,13 @@ fun ArtistDetailRoute(
|
||||
ArtistDetailScreen(
|
||||
artist = artist,
|
||||
artistName = artistName.orEmpty(),
|
||||
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
|
||||
albums = albums.orEmpty(),
|
||||
albums = albums ?: ArtistAlbums(),
|
||||
albumsLoaded = albums != null,
|
||||
bio = bioSnapshot.biography,
|
||||
bioLoading = bioLoading && !bioSnapshot.present,
|
||||
bioUnavailable = bioUnavailable && !bioSnapshot.present,
|
||||
bioPending = !bioSnapshot.known,
|
||||
bioExpanded = bioExpanded,
|
||||
art = art,
|
||||
artThumbnail = artThumbnail,
|
||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||
@@ -206,6 +241,8 @@ fun ArtistDetailRoute(
|
||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
||||
starred = artist?.starred != null,
|
||||
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
||||
onExpandBio = viewModel::expandBio,
|
||||
onCollapseBio = viewModel::collapseBio,
|
||||
onShuffle = viewModel::shuffle,
|
||||
)
|
||||
|
||||
@@ -225,9 +262,13 @@ fun ArtistDetailScreen(
|
||||
artist: Artist?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistName: String = "",
|
||||
isAlbumArtist: Boolean? = null,
|
||||
albums: List<Album>,
|
||||
albums: ArtistAlbums = ArtistAlbums(),
|
||||
albumsLoaded: Boolean = true,
|
||||
bio: String? = null,
|
||||
bioLoading: Boolean = false,
|
||||
bioUnavailable: Boolean = false,
|
||||
bioPending: Boolean = false,
|
||||
bioExpanded: Boolean = false,
|
||||
art: CoverArtRef?,
|
||||
artThumbnail: CoverArtRef? = null,
|
||||
artwork: ArtworkColors? = null,
|
||||
@@ -239,6 +280,8 @@ fun ArtistDetailScreen(
|
||||
onMore: () -> Unit = {},
|
||||
starred: Boolean = false,
|
||||
onToggleStar: (() -> Unit)? = null,
|
||||
onExpandBio: () -> Unit = {},
|
||||
onCollapseBio: () -> Unit = {},
|
||||
onShuffle: () -> Unit = {},
|
||||
) {
|
||||
val listState = rememberLazyGridState()
|
||||
@@ -252,9 +295,24 @@ fun ArtistDetailScreen(
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
||||
val albumCount = albums.size.toLong()
|
||||
val albumCount = albums.count.toLong()
|
||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
||||
val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
|
||||
val artistType =
|
||||
stringResource(
|
||||
if (albums.albumCredits.isNotEmpty()) R.string.artist_type_album_artist else R.string.artist_type_artist,
|
||||
)
|
||||
val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
||||
val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else ""
|
||||
val hasBio = !bio.isNullOrBlank()
|
||||
val bioCollapsed = hasBio && !bioExpanded
|
||||
val bioText =
|
||||
when {
|
||||
bioPending -> ""
|
||||
hasBio -> bio.orEmpty()
|
||||
bioLoading -> stringResource(R.string.artist_bio_loading)
|
||||
bioUnavailable -> stringResource(R.string.artist_bio_unavailable)
|
||||
else -> stringResource(R.string.artist_bio_empty)
|
||||
}
|
||||
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
|
||||
val titleMaxLines =
|
||||
@@ -306,7 +364,9 @@ fun ArtistDetailScreen(
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||
val barHeightPx = with(density) { barHeight.toPx() }
|
||||
val gradientScrollPx = scrollPx - imageHeightPx
|
||||
// The gradient is anchored to the hero image, not the whole header, whose bio can expand.
|
||||
val heroHeightPx = with(density) { ART_HEIGHT.toPx() }
|
||||
val gradientScrollPx = scrollPx - heroHeightPx
|
||||
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
||||
HeroGradient(
|
||||
colors = effectiveArtwork,
|
||||
@@ -384,100 +444,71 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = TITLE_INSET),
|
||||
) {
|
||||
Text(
|
||||
text = if (albumsLoaded) artistType else "",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
text = metaText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE),
|
||||
)
|
||||
ArtistBio(
|
||||
text = bioText,
|
||||
color =
|
||||
if (hasBio) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
collapsed = bioCollapsed,
|
||||
expanded = hasBio && bioExpanded,
|
||||
artist = artist,
|
||||
starred = starred,
|
||||
onToggleStar = onToggleStar,
|
||||
onMore = onMore,
|
||||
onExpand = onExpandBio,
|
||||
onCollapse = onCollapseBio,
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
bottom = if (hasBio && !bioCollapsed) 8.dp else 0.dp,
|
||||
),
|
||||
)
|
||||
// Hold the row at the button height even without the buttons, so the
|
||||
// label-to-count gap is the same for every artist type.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (albumsLoaded) {
|
||||
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (artist != null) {
|
||||
IconButton(
|
||||
onClick = { onToggleStar?.invoke() },
|
||||
enabled = onToggleStar != null,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (starred) R.string.actions_unstar else R.string.actions_star,
|
||||
),
|
||||
tint =
|
||||
if (starred) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onMore) {
|
||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
||||
val art = coverArt(album.coverArt, false)
|
||||
val thumbnail = coverArt(album.coverArt, true)
|
||||
PrefetchArtworkSeeds(thumbnail)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||
).combinedClickable(
|
||||
onClick = { onAlbumClick(album) },
|
||||
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
|
||||
).onSizeChanged { albumHeightPx = it.height.toFloat() },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||
) {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
name = album.name,
|
||||
thumbnailRef = thumbnail,
|
||||
showPlaceholder = art == null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
ListDownloadIndicator(
|
||||
status = albumDownloads[album.id],
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
||||
scrim = true,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = album.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
if (albums.albumCredits.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
ArtistSectionHeader(stringResource(R.string.artist_section_albums), Icons.Rounded.Album)
|
||||
}
|
||||
itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
|
||||
ArtistAlbumCard(
|
||||
album = album,
|
||||
index = index,
|
||||
coverArt = coverArt,
|
||||
downloadStatus = albumDownloads[album.id],
|
||||
onClick = { onAlbumClick(album) },
|
||||
onLongClick = { thumbnail ->
|
||||
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||
},
|
||||
onSizeChanged = { albumHeightPx = it },
|
||||
)
|
||||
Text(
|
||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
}
|
||||
}
|
||||
if (albums.appearances.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
ArtistSectionHeader(stringResource(R.string.artist_section_appearances), Icons.Rounded.Mic, topGap = 16.dp)
|
||||
}
|
||||
itemsIndexed(albums.appearances, key = { _, album -> album.id }) { index, album ->
|
||||
ArtistAlbumCard(
|
||||
album = album,
|
||||
index = index,
|
||||
coverArt = coverArt,
|
||||
downloadStatus = albumDownloads[album.id],
|
||||
onClick = { onAlbumClick(album) },
|
||||
onLongClick = { thumbnail ->
|
||||
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||
},
|
||||
onSizeChanged = { albumHeightPx = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -523,6 +554,388 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The expanded layout narrows only the lines that overlap the action buttons; later lines use the
|
||||
// full width.
|
||||
@Composable
|
||||
private fun ArtistBio(
|
||||
text: String,
|
||||
color: Color,
|
||||
collapsed: Boolean,
|
||||
expanded: Boolean,
|
||||
artist: Artist?,
|
||||
starred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
onMore: () -> Unit,
|
||||
onExpand: () -> Unit,
|
||||
onCollapse: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val density = LocalDensity.current
|
||||
val style = MaterialTheme.typography.bodyMedium
|
||||
val moreText = stringResource(R.string.artist_bio_more)
|
||||
val lessText = stringResource(R.string.artist_bio_less)
|
||||
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
|
||||
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
|
||||
val lineHeightPx =
|
||||
remember(style) {
|
||||
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
|
||||
}.coerceAtLeast(1)
|
||||
val reserveLines =
|
||||
ceil(with(density) { (BIO_ACTION_HEIGHT - BIO_ACTION_OFFSET).toPx() } / lineHeightPx)
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val bioLines =
|
||||
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) {
|
||||
when {
|
||||
collapsed -> {
|
||||
splitBioCollapsed(measurer, text, style, fullWidth, reserve, reserveLines, moreText, maxLines = 3)
|
||||
}
|
||||
|
||||
expanded -> {
|
||||
splitBioExpanded(measurer, text, style, fullWidth, reserve, reserveLines, lessText)
|
||||
}
|
||||
|
||||
else -> {
|
||||
BioLines(
|
||||
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
|
||||
AnnotatedString(it.ifEmpty { " " })
|
||||
},
|
||||
more = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
bioLines.lines.forEachIndexed { index, line ->
|
||||
val isControl = index == bioLines.lines.lastIndex && (bioLines.more || bioLines.less)
|
||||
Text(
|
||||
text = line,
|
||||
style = style,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
when {
|
||||
isControl && bioLines.more -> Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
|
||||
isControl && bioLines.less -> Modifier.testTag(ARTIST_BIO_LESS_TAG).clickable(onClick = onCollapse)
|
||||
else -> Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(y = -BIO_ACTION_OFFSET)) {
|
||||
ArtistActions(artist, starred, onToggleStar, onMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bioLineWidth(
|
||||
index: Int,
|
||||
fullWidth: Int,
|
||||
reserve: Int,
|
||||
reserveLines: Int,
|
||||
): Int = if (index < reserveLines) fullWidth - reserve else fullWidth
|
||||
|
||||
/** Splits text at every source line break, then wraps it to the width left by the actions. */
|
||||
internal fun splitBio(
|
||||
measurer: TextMeasurer,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
fullWidth: Int,
|
||||
reserve: Int,
|
||||
reserveLines: Int,
|
||||
): List<String> {
|
||||
val lines = mutableListOf<String>()
|
||||
text.split('\n').forEach { sourceLine ->
|
||||
val words = sourceLine.trim().split(' ').filter { it.isNotEmpty() }
|
||||
if (words.isEmpty()) {
|
||||
lines += ""
|
||||
return@forEach
|
||||
}
|
||||
var current = ""
|
||||
for (word in words) {
|
||||
val candidate = if (current.isEmpty()) word else "$current $word"
|
||||
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||
current = candidate
|
||||
} else if (current.isEmpty()) {
|
||||
lines += candidate
|
||||
} else {
|
||||
lines += current
|
||||
current = word
|
||||
}
|
||||
}
|
||||
if (current.isNotEmpty()) lines += current
|
||||
}
|
||||
while (lines.isNotEmpty() && lines.last().isBlank()) lines.removeAt(lines.lastIndex)
|
||||
return lines.ifEmpty { listOf("") }
|
||||
}
|
||||
|
||||
internal fun splitBioExpanded(
|
||||
measurer: TextMeasurer,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
fullWidth: Int,
|
||||
reserve: Int,
|
||||
reserveLines: Int,
|
||||
lessText: String,
|
||||
): BioLines {
|
||||
val lines = splitBio(measurer, text, style, fullWidth, reserve, reserveLines).mapTo(mutableListOf()) { AnnotatedString(it) }
|
||||
appendBioControl(lines, lessText, measurer, style, fullWidth, reserve, reserveLines)
|
||||
return BioLines(lines, more = false, less = true)
|
||||
}
|
||||
|
||||
private fun appendBioControl(
|
||||
lines: MutableList<AnnotatedString>,
|
||||
label: String,
|
||||
measurer: TextMeasurer,
|
||||
style: TextStyle,
|
||||
fullWidth: Int,
|
||||
reserve: Int,
|
||||
reserveLines: Int,
|
||||
) {
|
||||
while (lines.isNotEmpty() && lines.last().text.isBlank()) lines.removeAt(lines.lastIndex)
|
||||
val control =
|
||||
buildAnnotatedString {
|
||||
append(" ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(label) }
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
lines += control
|
||||
return
|
||||
}
|
||||
val lastIndex = lines.lastIndex
|
||||
val available = bioLineWidth(lastIndex, fullWidth, reserve, reserveLines)
|
||||
val combined =
|
||||
buildAnnotatedString {
|
||||
append(lines[lastIndex])
|
||||
append(control)
|
||||
}
|
||||
if (measurer.measure(combined, style, maxLines = 1).size.width <= available) {
|
||||
lines[lastIndex] = combined
|
||||
return
|
||||
}
|
||||
val words = lines[lastIndex].text.split(' ').filter { it.isNotEmpty() }
|
||||
if (words.size > 1) {
|
||||
val tailLine =
|
||||
buildAnnotatedString {
|
||||
append(words.last())
|
||||
append(control)
|
||||
}
|
||||
if (measurer.measure(tailLine, style, maxLines = 1).size.width <= available) {
|
||||
lines[lastIndex] = AnnotatedString(words.dropLast(1).joinToString(" "))
|
||||
lines += tailLine
|
||||
return
|
||||
}
|
||||
}
|
||||
lines += control
|
||||
}
|
||||
|
||||
internal data class BioLines(
|
||||
val lines: List<AnnotatedString>,
|
||||
val more: Boolean,
|
||||
val less: Boolean = false,
|
||||
)
|
||||
|
||||
/** Collapses text to [maxLines] lines, appending a bold More only when it does not fit. */
|
||||
internal fun splitBioCollapsed(
|
||||
measurer: TextMeasurer,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
fullWidth: Int,
|
||||
reserve: Int,
|
||||
reserveLines: Int,
|
||||
moreText: String,
|
||||
maxLines: Int = 2,
|
||||
): BioLines {
|
||||
val words =
|
||||
text
|
||||
.replace("\n\n", " ")
|
||||
.replace('\n', ' ')
|
||||
.split(' ')
|
||||
.filter { it.isNotEmpty() }
|
||||
if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false)
|
||||
val lines = mutableListOf<AnnotatedString>()
|
||||
var index = 0
|
||||
while (index < words.size && lines.size < maxLines - 1) {
|
||||
var current = ""
|
||||
while (index < words.size) {
|
||||
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
|
||||
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||
current = candidate
|
||||
index++
|
||||
} else if (current.isEmpty()) {
|
||||
current = words[index]
|
||||
index++
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
lines += AnnotatedString(current)
|
||||
}
|
||||
if (index >= words.size) {
|
||||
return BioLines(lines, more = false)
|
||||
}
|
||||
|
||||
val remainingAvailable = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||
val remaining = words.drop(index).joinToString(" ")
|
||||
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= remainingAvailable) {
|
||||
lines += AnnotatedString(remaining)
|
||||
return BioLines(lines, more = false)
|
||||
}
|
||||
|
||||
val more =
|
||||
buildAnnotatedString {
|
||||
append("… ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||
}
|
||||
val moreWidth =
|
||||
measurer.measure(more, style, maxLines = 1).size.width +
|
||||
measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
|
||||
val lastAvailable = (bioLineWidth(lines.size, fullWidth, reserve, reserveLines) - moreWidth).coerceAtLeast(1)
|
||||
var truncated = ""
|
||||
while (index < words.size) {
|
||||
val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}"
|
||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= lastAvailable) {
|
||||
truncated = candidate
|
||||
index++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
val lastLine =
|
||||
buildAnnotatedString {
|
||||
if (truncated.isNotEmpty()) append("$truncated ")
|
||||
append("… ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||
}
|
||||
lines += lastLine
|
||||
return BioLines(lines, more = true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSectionHeader(
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
topGap: Dp = 0.dp,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = topGap, bottom = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 1.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistActions(
|
||||
artist: Artist?,
|
||||
starred: Boolean,
|
||||
onToggleStar: (() -> Unit)?,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
if (artist == null) return
|
||||
IconButton(
|
||||
onClick = { onToggleStar?.invoke() },
|
||||
enabled = onToggleStar != null,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (starred) R.string.actions_unstar else R.string.actions_star,
|
||||
),
|
||||
tint =
|
||||
if (starred) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onMore) {
|
||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistAlbumCard(
|
||||
album: Album,
|
||||
index: Int,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
downloadStatus: ListDownloadStatus?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (CoverArtRef?) -> Unit,
|
||||
onSizeChanged: (Float) -> Unit,
|
||||
) {
|
||||
val art = coverArt(album.coverArt, false)
|
||||
val thumbnail = coverArt(album.coverArt, true)
|
||||
PrefetchArtworkSeeds(thumbnail)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||
).combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = { onLongClick(thumbnail) },
|
||||
).onSizeChanged { onSizeChanged(it.height.toFloat()) },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||
) {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
name = album.name,
|
||||
thumbnailRef = thumbnail,
|
||||
showPlaceholder = art == null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
ListDownloadIndicator(
|
||||
status = downloadStatus,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
||||
scrim = true,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = album.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistTitle(
|
||||
text: String,
|
||||
|
||||
@@ -2,8 +2,9 @@ package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -13,6 +14,7 @@ import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -50,10 +52,10 @@ class ArtistDetailViewModel(
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val albums: Flow<List<Album>?> =
|
||||
val albums: Flow<ArtistAlbums?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.map<List<Album>, List<Album>?> { it }
|
||||
.map<ArtistAlbums, ArtistAlbums?> { it }
|
||||
.onStart { emit(null) }
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
@@ -66,6 +68,52 @@ 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) }
|
||||
|
||||
private val _bioLoading = MutableStateFlow(true)
|
||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||
|
||||
private val _bioUnavailable = MutableStateFlow(false)
|
||||
val bioUnavailable: StateFlow<Boolean> = _bioUnavailable
|
||||
|
||||
private val _bioExpanded = MutableStateFlow(false)
|
||||
val bioExpanded: StateFlow<Boolean> = _bioExpanded
|
||||
|
||||
private var bioRefresh: Job? = null
|
||||
|
||||
fun refreshBio() {
|
||||
if (bioRefresh?.isActive == true) return
|
||||
bioRefresh =
|
||||
viewModelScope.launch {
|
||||
_bioUnavailable.value = false
|
||||
if (sourceRepository.offline.value) {
|
||||
_bioLoading.value = false
|
||||
_bioUnavailable.value = true
|
||||
return@launch
|
||||
}
|
||||
val id = sourceRepository.activeSourceIdOnce()
|
||||
if (id == null) {
|
||||
_bioLoading.value = false
|
||||
_bioUnavailable.value = true
|
||||
return@launch
|
||||
}
|
||||
_bioLoading.value = true
|
||||
try {
|
||||
_bioUnavailable.value = libraryRepository.refreshArtistBio(id, artistId).isFailure
|
||||
} finally {
|
||||
_bioLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun expandBio() {
|
||||
_bioExpanded.value = true
|
||||
}
|
||||
|
||||
fun collapseBio() {
|
||||
_bioExpanded.value = false
|
||||
}
|
||||
|
||||
suspend fun downloadedBytes(
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
@@ -92,6 +93,7 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
@@ -272,6 +274,7 @@ fun LibraryRoute(
|
||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||
val downloads = downloadsState?.value ?: emptyMap()
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val itemActions =
|
||||
@@ -311,7 +314,10 @@ fun LibraryRoute(
|
||||
onSync = viewModel::sync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||
onItemLongClick = {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost?.show(it, itemActions)
|
||||
},
|
||||
listQuery = displayQuery,
|
||||
resetKeys = resetKeys,
|
||||
offline = offline,
|
||||
@@ -424,9 +430,14 @@ fun LibraryScreen(
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var topRequest by remember { mutableIntStateOf(0) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
val closeSearch = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
}
|
||||
|
||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||
DismissOnRequest(dismissals) { showOptions = false }
|
||||
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||
@@ -445,14 +456,8 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSearch() {
|
||||
if (searchActive) {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||
@@ -503,7 +508,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onAlbumClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -523,7 +528,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onArtistClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -543,7 +548,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onPlaylistClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -595,10 +600,7 @@ fun LibraryScreen(
|
||||
SearchField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
onClose = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
},
|
||||
onClose = closeSearch,
|
||||
focusRequester = searchFocus,
|
||||
modifier =
|
||||
Modifier
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
|
||||
private val TRACK_COLUMN_WIDTH = 24.dp
|
||||
private val TIME_COLUMN_WIDTH = 36.dp
|
||||
@@ -146,7 +147,7 @@ fun SongRow(
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
||||
text = song.artistSubtitle().orEmpty().ifEmpty { "\u00A0" },
|
||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -85,6 +86,7 @@ fun SearchRoute(
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
||||
val actions =
|
||||
ItemActions(
|
||||
@@ -106,11 +108,26 @@ fun SearchRoute(
|
||||
coverArt = viewModel::coverArt,
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onAlbumClick = {
|
||||
focusManager.clearFocus()
|
||||
onAlbumClick(it)
|
||||
},
|
||||
onArtistClick = {
|
||||
focusManager.clearFocus()
|
||||
onArtistClick(it)
|
||||
},
|
||||
onPlaylistClick = {
|
||||
focusManager.clearFocus()
|
||||
onPlaylistClick(it)
|
||||
},
|
||||
onSongClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.play(it)
|
||||
},
|
||||
onLongClick = {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost?.show(it, actions)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ internal fun replayGainModeLabel(
|
||||
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
||||
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
||||
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
|
||||
ReplayGainMode.Auto -> resources.getString(R.string.settings_replay_gain_mode_auto)
|
||||
}
|
||||
|
||||
internal fun replayGainPreampLabel(
|
||||
|
||||
@@ -203,7 +203,7 @@ fun SettingsScreen(
|
||||
verboseLogging: Boolean = false,
|
||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||
replayGainPreamp: Float = 0f,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
|
||||
@@ -69,7 +69,7 @@ class SettingsViewModel(
|
||||
val replayGainMode: StateFlow<ReplayGainMode> =
|
||||
userPreferences
|
||||
.replayGainMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Auto)
|
||||
|
||||
val replayGainPreamp: StateFlow<Float> =
|
||||
userPreferences
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
<string name="resources_filter_artist">Artist</string>
|
||||
<string name="artist_type_artist">Artist</string>
|
||||
<string name="artist_type_album_artist">Album artist</string>
|
||||
<string name="artist_section_albums">Albums</string>
|
||||
<string name="artist_section_appearances">Appears on</string>
|
||||
<string name="artist_bio_empty">No artist bio available.</string>
|
||||
<string name="artist_bio_loading">Loading artist bio…</string>
|
||||
<string name="artist_bio_unavailable">Artist bio unavailable.</string>
|
||||
<string name="artist_bio_more">More</string>
|
||||
<string name="artist_bio_less">Less</string>
|
||||
<string name="artist_meta">%1$s · %2$s</string>
|
||||
<string name="resources_filter_genre">Genre</string>
|
||||
<string name="resources_filter_starred">Starred</string>
|
||||
<string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>
|
||||
@@ -75,6 +83,7 @@
|
||||
<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>
|
||||
<string name="settings_replay_gain_mode_album">Album gain</string>
|
||||
<string name="settings_replay_gain_mode_auto">Auto</string>
|
||||
<string name="settings_replay_gain_mode_off">Off</string>
|
||||
<string name="settings_replay_gain_mode_track">Track gain</string>
|
||||
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -182,13 +184,14 @@ class LibraryDaoTest {
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", playCount = 3, played = 100),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", playCount = 4, played = 200),
|
||||
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-2", playCount = 5, played = 400),
|
||||
song(sourceId, "s2", "Two", starred = null)
|
||||
.copy(albumId = "al-2", artistId = "ar-2", playCount = 4, played = 200),
|
||||
song(sourceId, "s3", "Three", starred = null)
|
||||
.copy(albumId = "al-2", artistId = "ar-2", playCount = 5, played = 400),
|
||||
),
|
||||
)
|
||||
|
||||
dao.recomputeAlbumPlayData(sourceId)
|
||||
dao.recomputeArtistPlayData(sourceId)
|
||||
dao.recomputeAggregates(sourceId)
|
||||
|
||||
assertEquals(3L, dao.albumOnce(sourceId, "al-1")!!.playCount)
|
||||
assertEquals(100L, dao.albumOnce(sourceId, "al-1")!!.played)
|
||||
@@ -514,6 +517,160 @@ class LibraryDaoTest {
|
||||
assertEquals(listOf("al-1"), dao.albumsForArtistDownloaded(sourceId, "ar-guest").first().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumCreditsAreSeparatedFromTrackAppearances() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Own", year = null, starred = null),
|
||||
album(sourceId, "al-2", "Other", year = null, starred = null).copy(artistId = "ar-2"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("al-1"), dao.albumCreditAlbumIds(sourceId, "ar-1").first())
|
||||
assertEquals(listOf("al-2"), dao.albumCreditAlbumIds(sourceId, "ar-2").first())
|
||||
assertEquals(
|
||||
listOf("al-1", "al-2"),
|
||||
dao
|
||||
.albumsForArtist(sourceId, "ar-1")
|
||||
.first()
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-own", "Own", albumCount = 1).copy(coverArt = "own-art"),
|
||||
artist(sourceId, "ar-album", "Album", albumCount = 2),
|
||||
artist(sourceId, "ar-track", "Track", albumCount = 1),
|
||||
artist(sourceId, "ar-album-ref", "Album Ref", albumCount = 1),
|
||||
artist(sourceId, "ar-track-ref", "Track Ref", albumCount = 1),
|
||||
artist(sourceId, "ar-null-art", "Null Art", albumCount = 2),
|
||||
artist(sourceId, "ar-none", "None", albumCount = 0),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "own", "Own", year = 2020, starred = null).copy(coverArt = "own-album"),
|
||||
album(sourceId, "album-new", "New", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-album", coverArt = "new-art"),
|
||||
album(sourceId, "album-old", "Old", year = 2000, starred = null)
|
||||
.copy(artistId = "ar-album", coverArt = "old-art"),
|
||||
album(sourceId, "track", "Track", year = 2021, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "track-art"),
|
||||
album(sourceId, "album-ref", "Album Ref", year = 2021, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "album-ref-art"),
|
||||
album(sourceId, "track-ref", "Track Ref", year = 2019, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "track-ref-art"),
|
||||
album(sourceId, "null-new", "Null New", year = 2022, starred = null)
|
||||
.copy(artistId = "ar-null-art", coverArt = null),
|
||||
album(sourceId, "null-old", "Null Old", year = 1990, starred = null)
|
||||
.copy(artistId = "ar-null-art", coverArt = "later-art"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s-own", "Own", starred = null).copy(albumId = "own", artistId = "ar-own"),
|
||||
song(sourceId, "s-album", "Album", starred = null).copy(albumId = "album-new", artistId = "ar-album"),
|
||||
song(sourceId, "s-track", "Track", starred = null).copy(albumId = "track", artistId = "ar-track"),
|
||||
song(sourceId, "s-album-ref", "Album Ref", starred = null)
|
||||
.copy(albumId = "album-ref", artistId = "ar-other"),
|
||||
song(sourceId, "s-track-ref", "Track Ref", starred = null)
|
||||
.copy(albumId = "track-ref", artistId = "ar-other"),
|
||||
song(sourceId, "s-null-new", "Null New", starred = null)
|
||||
.copy(albumId = "null-new", artistId = "ar-null-art"),
|
||||
song(sourceId, "s-null-old", "Null Old", starred = null)
|
||||
.copy(albumId = "null-old", artistId = "ar-null-art"),
|
||||
),
|
||||
)
|
||||
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (?, 'album-ref', 0, 'ar-album-ref', 'Album Ref')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (?, 's-track-ref', 0, 'ar-track-ref', 'Track Ref')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
|
||||
|
||||
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
||||
assertEquals("new-art", artists.getValue("ar-album").coverArt)
|
||||
assertEquals("track-art", artists.getValue("ar-track").coverArt)
|
||||
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt)
|
||||
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt)
|
||||
assertNull(artists.getValue("ar-null-art").coverArt)
|
||||
assertNull(artists.getValue("ar-none").coverArt)
|
||||
assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt)
|
||||
assertEquals(
|
||||
"album-ref-art",
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
||||
.page()
|
||||
.first { it.id == "ar-album-ref" }
|
||||
.coverArt,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
|
||||
runTest {
|
||||
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20")
|
||||
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumCreditIdsFindCreditsThroughArtistIndexes() =
|
||||
runTest {
|
||||
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
|
||||
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertFalse(plan, plan.contains("SCAN albums"))
|
||||
assertFalse(plan, plan.contains("SCAN songs"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedArtistsIncludeOneCreditedOnlyOnADownloadedTrack() =
|
||||
runTest {
|
||||
@@ -607,7 +764,7 @@ class LibraryDaoTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedFilterShowsDownloadedAlbumCountsButSortsByTheSyncedCount() =
|
||||
fun downloadedFilterSortsByTheDownloadedAlbumCountItShows() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
@@ -635,11 +792,11 @@ class LibraryDaoTest {
|
||||
db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed))
|
||||
}
|
||||
|
||||
// ar-1 has two downloaded albums, ar-2 one. The list still orders by the synced count
|
||||
// (so the album-count index stays usable) but reports the downloaded count.
|
||||
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
|
||||
// though the synced counts would put ar-2 first.
|
||||
val filtered = dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page()
|
||||
assertEquals(listOf("ar-2", "ar-1"), filtered.map { it.id })
|
||||
assertEquals(listOf(1L, 2L), filtered.map { it.albumCount })
|
||||
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
|
||||
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -855,6 +1012,25 @@ class LibraryDaoTest {
|
||||
assertTrue(dao.searchSongs(sourceId, "ga", 50).first().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistPageAlbumQueryFindsArtistCreditsThroughArtistIndexes() =
|
||||
runTest {
|
||||
val plan = plan(ALBUMS_FOR_ARTIST)
|
||||
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertFalse(plan, plan.contains("SCAN albums"))
|
||||
assertFalse(plan, plan.contains("SCAN songs"))
|
||||
}
|
||||
|
||||
private suspend fun plan(sql: String): String =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||
|
||||
@@ -136,6 +136,23 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistBioTableStoresTheLatestBiography() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 34)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
|
||||
MIGRATION_34_35.migrate(migrated)
|
||||
migrated.execSQL("INSERT INTO artist_bios (sourceId, artistId, biography) VALUES (1, 'ar1', 'Old')")
|
||||
migrated.execSQL("UPDATE artist_bios SET biography = 'New' WHERE sourceId = 1 AND artistId = 'ar1'")
|
||||
|
||||
assertEquals("New", text(migrated, "SELECT biography FROM artist_bios WHERE artistId = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||
runTest {
|
||||
@@ -272,6 +289,94 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCreditTotalsBackfillUsesEveryCreditedSong() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 33)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (2, 't', 'http://y', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (3, 'u', 'http://z', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (1, 'a1', 'Album Artist', 1, 0, 0, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (1, 'a2', 'Guest', 0, 0, 0, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (2, 'b1', 'Unsynced', 7, 6, 5, 100)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (3, 'c1', 'Track Only', 7, 6, 5, 100)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al1', 'a1', 'Album', 'Album Artist', 0, 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al2', 'a2', 'Empty Album', 'Guest', 0, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (1, 'al1', 0, 'a1', 'Album Artist')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||
"VALUES (1, 's1', 'al1', 'a1', 'One', 4, 200)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (1, 's1', 0, 'a1', 'Album Artist')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (1, 's1', 1, 'a2', 'Guest')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||
"VALUES (3, 's3', NULL, 'c1', 'Three', 2, 50)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (3, 's3', 0, 'c1', 'Track Only')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||
"VALUES (3, 's4', 'missing', 'c1', 'Four', 3, 60)",
|
||||
)
|
||||
|
||||
MIGRATION_33_34.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(2L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(6L, long(migrated, "SELECT songCount FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(7L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(100L, long(migrated, "SELECT played FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(2L, long(migrated, "SELECT songCount FROM artists WHERE id = 'c1'"))
|
||||
assertEquals(0L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'c1'"))
|
||||
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'c1'"))
|
||||
assertEquals(60L, long(migrated, "SELECT played FROM artists WHERE id = 'c1'"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun text(
|
||||
connection: SQLiteConnection,
|
||||
sql: String,
|
||||
|
||||
@@ -134,7 +134,7 @@ class QueryTimingMeasurementTest {
|
||||
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
|
||||
timed("home genres") { dao.genresByMostPlayed(1).first() }
|
||||
timed("home decades") { dao.decades(1).first() }
|
||||
timed("recompute play data") { dao.recomputePlayData(1) }
|
||||
timed("recompute aggregates") { dao.recomputeAggregates(1) }
|
||||
}
|
||||
|
||||
private suspend fun <T : Any> PagingSource<Int, T>.firstPage(): List<T> =
|
||||
|
||||
@@ -210,13 +210,13 @@ class UserPreferencesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replayGainDefaultsToTrackModeAndRoundTrips() =
|
||||
fun replayGainDefaultsToAutoModeAndRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
|
||||
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
|
||||
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainMode(ReplayGainMode.Album)
|
||||
@@ -225,6 +225,9 @@ class UserPreferencesTest {
|
||||
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
|
||||
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainMode(ReplayGainMode.Auto)
|
||||
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
|
||||
|
||||
prefs.setReplayGainPreampDb(99f)
|
||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -49,6 +53,7 @@ import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DownloadRepositoryTest {
|
||||
@@ -121,6 +126,49 @@ class DownloadRepositoryTest {
|
||||
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDownloadAlsoFetchesTheArtistsBiography() =
|
||||
runTest {
|
||||
val server = MockWebServer()
|
||||
server.start()
|
||||
try {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
when (request.requestUrl!!.encodedPath) {
|
||||
"/rest/ping.view" -> {
|
||||
MockResponse().setBody("<subsonic-response status=\"ok\"/>")
|
||||
}
|
||||
|
||||
"/rest/getArtistInfo2.view" -> {
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||
"<biography>Downloaded bio</biography>" +
|
||||
"</artistInfo2></subsonic-response>",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
seedLibrary(server.url("/").toString())
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
await { artistBio(1, "ar1") != null }
|
||||
assertEquals("Downloaded bio", artistBio(1, "ar1")?.biography)
|
||||
var request = server.takeRequest(5, TimeUnit.SECONDS)!!
|
||||
while (request.requestUrl!!.encodedPath != "/rest/getArtistInfo2.view") {
|
||||
request = server.takeRequest(5, TimeUnit.SECONDS)!!
|
||||
}
|
||||
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
|
||||
} finally {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
|
||||
runTest {
|
||||
@@ -1237,9 +1285,9 @@ class DownloadRepositoryTest {
|
||||
private fun artist(coverArt: String?) =
|
||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
|
||||
|
||||
private fun seedLibrary() {
|
||||
private fun seedLibrary(address: String = "http://localhost:4533/") {
|
||||
runBlocking {
|
||||
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
|
||||
db.sourcesDao().upsertSource(Source(1, "source", address, isActive = true, createdAt = 0))
|
||||
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
@@ -1289,6 +1337,11 @@ class DownloadRepositoryTest {
|
||||
songId: String,
|
||||
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
|
||||
|
||||
private fun artistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
) = runBlocking { db.libraryDao().artistBioOnce(sourceId, artistId) }
|
||||
|
||||
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
|
||||
|
||||
private fun writeFile(
|
||||
|
||||
@@ -12,7 +12,11 @@ import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
@@ -203,6 +207,161 @@ class LibraryRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||
runBlocking {
|
||||
val sourceId = 1L
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(
|
||||
listOf(
|
||||
seedAlbum(sourceId, 1),
|
||||
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
),
|
||||
)
|
||||
|
||||
val albums = repository.artistAlbums(sourceId, "ar1").first()
|
||||
|
||||
assertEquals(listOf("al-0001"), albums.albumCredits.map { it.id })
|
||||
assertEquals(listOf("al-0002"), albums.appearances.map { it.id })
|
||||
assertEquals(2, albums.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedArtistAlbumsKeepPerCreditVisibility() =
|
||||
runBlocking {
|
||||
val sourceId = 1L
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(
|
||||
listOf(
|
||||
seedAlbum(sourceId, 1),
|
||||
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
seedSong(sourceId).copy(id = "s3", albumId = "al-0002", artistId = "ar2"),
|
||||
seedSong(sourceId).copy(id = "s4", albumId = "al-0001", artistId = "ar2"),
|
||||
),
|
||||
)
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s4", DownloadStatus.Completed))
|
||||
val otherOnAlbumCredit = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
|
||||
assertEquals(listOf("al-0001"), otherOnAlbumCredit.albumCredits.map { it.id })
|
||||
assertEquals(emptyList<String>(), otherOnAlbumCredit.appearances.map { it.id })
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed))
|
||||
val otherOnTrackCredit = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
|
||||
assertEquals(listOf("al-0001"), otherOnTrackCredit.albumCredits.map { it.id })
|
||||
assertEquals(emptyList<String>(), otherOnTrackCredit.appearances.map { it.id })
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
||||
val creditedTrack = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
|
||||
assertEquals(listOf("al-0001"), creditedTrack.albumCredits.map { it.id })
|
||||
assertEquals(listOf("al-0002"), creditedTrack.appearances.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshingAnArtistBioStoresItAndUpdatesItOnTheNextOpen() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
server.enqueue(artistInfo("First bio"))
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
assertEquals("First bio", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
|
||||
server.enqueue(artistInfo("Second bio"))
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
|
||||
assertEquals("Second bio", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
val first = server.takeRequest()
|
||||
assertEquals("/rest/getArtistInfo2.view", first.requestUrl!!.encodedPath)
|
||||
assertEquals("ar1", first.requestUrl!!.queryParameter("id"))
|
||||
assertEquals("/rest/getArtistInfo2.view", server.takeRequest().requestUrl!!.encodedPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistWithoutABioStillRecordsTheFetchedState() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
|
||||
val bio = repository.artistBio(sourceId, "ar1").first()
|
||||
assertNotNull(bio)
|
||||
assertNull(bio?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyRefreshKeepsTheStoredBio() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
|
||||
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun biographiesKeepLineAndParagraphBreaks() {
|
||||
assertEquals("Line one\nLine two", normalizeBiography("Line one\r\nLine two"))
|
||||
assertEquals("Line one\nLine two", normalizeBiography("Line one\nLine two"))
|
||||
assertEquals("One\n\nTwo", normalizeBiography("One Two"))
|
||||
assertEquals("One\n\nTwo", normalizeBiography("One\n\nTwo"))
|
||||
assertNull(normalizeBiography(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPreviouslyStoredBioIsNormalizedWhenRead() =
|
||||
runBlocking {
|
||||
withSource {
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "One Two"))
|
||||
assertEquals("One\n\nTwo", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRefreshKeepsTheStoredBio() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isFailure)
|
||||
|
||||
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitRefreshes(
|
||||
presenter: RecordingPresenter,
|
||||
target: Int,
|
||||
@@ -279,6 +438,13 @@ class LibraryRepositoryTest {
|
||||
genre = null,
|
||||
)
|
||||
|
||||
private fun artistInfo(biography: String) =
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||
"<biography>$biography</biography>" +
|
||||
"</artistInfo2></subsonic-response>",
|
||||
)
|
||||
|
||||
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
|
||||
|
||||
private fun failed(code: Int) =
|
||||
|
||||
@@ -485,6 +485,80 @@ class QueueRepositoryTest {
|
||||
repository.move(repository.snapshot(), from = 2, to = 1)
|
||||
|
||||
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||
val (entry, _) = repository.snapshot().locate(1)!!
|
||||
assertEquals(QueueKind.Song, entry.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAQueuedAlbumTrackKeepsItsAlbumEntry() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
|
||||
|
||||
repository.move(repository.snapshot(), from = 2, to = 1)
|
||||
|
||||
val (entry, _) = repository.snapshot().locate(1)!!
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals("al2", entry.refId)
|
||||
assertEquals(1L, entry.rangeStart)
|
||||
assertEquals(1L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingARangedAlbumTrackInUpNextKeepsTheAbsoluteOffsets() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
song("s6", "al1", track = 4, album = "First Album"),
|
||||
song("s7", "al1", track = 5, album = "First Album"),
|
||||
song("x1", "al3", track = 1),
|
||||
),
|
||||
)
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false)
|
||||
repository.addUpNext(
|
||||
repository.snapshot(),
|
||||
currentPosition = 0,
|
||||
entry = repository.songEntry(1, "x1"),
|
||||
playNext = false,
|
||||
)
|
||||
assertEquals(listOf("s4", "s2", "s3", "s6", "s7", "x1", "s5"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.move(repository.snapshot(), from = 4, to = 5)
|
||||
|
||||
var updated = repository.snapshot()
|
||||
assertEquals(listOf("s4", "s2", "s3", "s6", "x1", "s7", "s5"), resolveAll(updated))
|
||||
val moved = updated.locate(5)!!.first
|
||||
assertEquals(QueueKind.Album, moved.kind)
|
||||
assertEquals(4L, moved.rangeStart)
|
||||
assertEquals(4L, moved.rangeEnd)
|
||||
|
||||
repository.move(repository.snapshot(), from = 5, to = 4)
|
||||
|
||||
updated = repository.snapshot()
|
||||
assertEquals(listOf("s4", "s2", "s3", "s6", "s7", "x1", "s5"), resolveAll(updated))
|
||||
assertTrue(updated.isAlbumContext(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAPlaylistTrackKeepsItAPinnedSong() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "p1", "s1", 0),
|
||||
PlaylistSong(1, "p1", "s4", 1),
|
||||
),
|
||||
)
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
|
||||
repository.move(repository.snapshot(), from = 0, to = 1)
|
||||
|
||||
val (entry, _) = repository.snapshot().locate(1)!!
|
||||
assertEquals(QueueKind.Song, entry.kind)
|
||||
assertEquals("s1", entry.refId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -853,11 +927,75 @@ class QueueRepositoryTest {
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
|
||||
val (entry, _) = repository.snapshot().locate(2)!!
|
||||
assertEquals(QueueKind.Song, entry.kind)
|
||||
assertEquals("s1", entry.refId)
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals("al1", entry.refId)
|
||||
assertEquals(0L, entry.rangeStart)
|
||||
assertEquals(0L, entry.rangeEnd)
|
||||
assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingWithinARangedAlbumKeepsTheAbsoluteOffsets() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
song("s6", "al1", track = 4, album = "First Album"),
|
||||
song("s7", "al1", track = 5, album = "First Album"),
|
||||
),
|
||||
)
|
||||
repository.snapshotAfter(listOf(albumRange(1, 4)))
|
||||
assertEquals(listOf("s2", "s3", "s6", "s7"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
|
||||
val snapshot = repository.snapshot()
|
||||
assertEquals(listOf("s3", "s6", "s2", "s7"), resolveAll(snapshot))
|
||||
val (entry, _) = snapshot.locate(2)!!
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals(1L, entry.rangeStart)
|
||||
assertEquals(1L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingBackwardWithinARangedAlbumKeepsTheAbsoluteOffsets() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
song("s6", "al1", track = 4, album = "First Album"),
|
||||
song("s7", "al1", track = 5, album = "First Album"),
|
||||
),
|
||||
)
|
||||
repository.snapshotAfter(listOf(albumRange(1, 4)))
|
||||
|
||||
repository.move(repository.snapshot(), from = 3, to = 0)
|
||||
|
||||
val snapshot = repository.snapshot()
|
||||
assertEquals(listOf("s7", "s2", "s3", "s6"), resolveAll(snapshot))
|
||||
val (entry, _) = snapshot.locate(0)!!
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals(4L, entry.rangeStart)
|
||||
assertEquals(4L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAnAlbumTrackBackCompactsTheAlbumEntry() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.replace(listOf(repository.albumEntry(1, "al1")))
|
||||
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
repository.move(repository.snapshot(), from = 2, to = 0)
|
||||
|
||||
val snapshot = repository.snapshot()
|
||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot))
|
||||
val entry = snapshot.entries.single().entry
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals(0L, entry.rangeStart)
|
||||
assertEquals(2L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() =
|
||||
runTest {
|
||||
|
||||
@@ -111,6 +111,37 @@ class SubsonicSourceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistBiographyReadsGetArtistInfo2() =
|
||||
runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||
"<biography>From Last.fm</biography>" +
|
||||
"</artistInfo2></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
val biography = SubsonicSource(1, client()).artistBiography("ar1")
|
||||
|
||||
assertEquals("From Last.fm", biography)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/rest/getArtistInfo2.view", request.requestUrl!!.encodedPath)
|
||||
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistBiographyIsNullWhenTheServerHasNone() =
|
||||
runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(SubsonicSource(1, client()).artistBiography("ar1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistsAreEmittedInBoundedBatches() =
|
||||
runBlocking {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.source.subsonic
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.xml.sax.Attributes
|
||||
@@ -42,6 +43,30 @@ class SubsonicXmlTest {
|
||||
onChild = SubsonicXml::songChild,
|
||||
).map { it.toSong() }
|
||||
|
||||
@Test
|
||||
fun readsTheArtistBiography() {
|
||||
val biography =
|
||||
SubsonicXml.artistBiography(
|
||||
(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||
"<biography> Radiohead & friends </biography>" +
|
||||
"</artistInfo2></subsonic-response>"
|
||||
).byteInputStream(Charsets.UTF_8),
|
||||
)
|
||||
|
||||
assertEquals("Radiohead & friends", biography)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistWithoutABiographyReadsAsNull() {
|
||||
val biography =
|
||||
SubsonicXml.artistBiography(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>".byteInputStream(Charsets.UTF_8),
|
||||
)
|
||||
|
||||
assertNull(biography)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsArtists() {
|
||||
val artists =
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||
import com.subtracks.data.db.SqlExecution
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -29,9 +31,11 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Collections
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncServiceTest {
|
||||
@@ -1373,6 +1377,11 @@ class SyncServiceTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists =
|
||||
listOf(
|
||||
artist("a1", "Artist").copy(albumCount = 9),
|
||||
artist("a2", "Other").copy(albumCount = 9),
|
||||
),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))),
|
||||
@@ -1465,6 +1474,398 @@ class SyncServiceTest {
|
||||
assertEquals(500L, db.libraryDao().artistOnce(1, "a2")!!.played)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trackOnlyCreditsReachTheArtistBadgeAndPlays() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest").copy(albumCount = 7)),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "Album Artist")),
|
||||
),
|
||||
album("al2").copy(
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a2", "Guest")),
|
||||
),
|
||||
),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
|
||||
playCount = 3,
|
||||
played = 100,
|
||||
),
|
||||
song("s2").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a2", "Guest")),
|
||||
playCount = 4,
|
||||
played = 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val guest = db.libraryDao().artistOnce(1, "a2")!!
|
||||
assertEquals(2L, guest.albumCount)
|
||||
assertEquals(2L, guest.songCount)
|
||||
assertEquals(7L, guest.playCount)
|
||||
assertEquals(200L, guest.played)
|
||||
val albums = db.libraryDao().albumsForArtist(1, "a2").first()
|
||||
assertEquals(listOf("al1", "al2"), albums.map { it.id }.sorted())
|
||||
assertEquals(albums.size.toLong(), guest.albumCount)
|
||||
assertEquals(
|
||||
albums.size.toLong(),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.allRows()
|
||||
.first { it.id == "a2" }
|
||||
.albumCount,
|
||||
)
|
||||
assertEquals(guest.songCount, db.queueDao().artistLength(1, "a2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsOnMissingAlbumsAreSongsButNotAlbums() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Artist")),
|
||||
albums = emptyList(),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "missing",
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "Artist")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val artist = db.libraryDao().artistOnce(1, "a1")!!
|
||||
assertEquals(1L, artist.songCount)
|
||||
assertEquals(0L, artist.albumCount)
|
||||
assertEquals(
|
||||
emptyList<String>(),
|
||||
db
|
||||
.libraryDao()
|
||||
.albumsForArtist(1, "a1")
|
||||
.first()
|
||||
.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumlessTrackCreditsCountAndBumpOnPlay() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest")),
|
||||
albums = emptyList(),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = null,
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
|
||||
playCount = 2,
|
||||
played = 50,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val guest = db.libraryDao().artistOnce(1, "a2")!!
|
||||
assertEquals(0L, guest.albumCount)
|
||||
assertEquals(1L, guest.songCount)
|
||||
assertEquals(2L, guest.playCount)
|
||||
assertEquals(50L, guest.played)
|
||||
|
||||
db.libraryDao().recordPlay(1, "s1", 300)
|
||||
|
||||
assertEquals(3L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
|
||||
assertEquals(300L, db.libraryDao().artistOnce(1, "a2")!!.played)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leadIdsOutsideThePluralRefsKeepArtistTotalsAndShufflesAligned() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Lead"), artist("a2", "Ref")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a2", "Ref")),
|
||||
),
|
||||
),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a2", "Ref")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val lead = db.libraryDao().artistOnce(1, "a1")!!
|
||||
assertEquals(1L, lead.songCount)
|
||||
assertEquals(1L, lead.albumCount)
|
||||
assertEquals(db.queueDao().artistLength(1, "a1"), lead.songCount)
|
||||
assertEquals(
|
||||
lead.songCount,
|
||||
db
|
||||
.downloadDao()
|
||||
.artistStatus(1, "a1")
|
||||
.first()
|
||||
.total,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumCreditIdsFollowPluralCredits() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "A"), artist("a2", "B")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A"))),
|
||||
album("al2").copy(artistId = "a2", artists = listOf(ArtistRef("a2", "B"))),
|
||||
album("al3").copy(
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
|
||||
),
|
||||
),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "A")),
|
||||
),
|
||||
song("s2").copy(
|
||||
albumId = "al2",
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a2", "B"), ArtistRef("a1", "A")),
|
||||
),
|
||||
song("s3").copy(
|
||||
albumId = "al3",
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(
|
||||
listOf("al1", "al3"),
|
||||
db
|
||||
.libraryDao()
|
||||
.albumCreditAlbumIds(1, "a1")
|
||||
.first()
|
||||
.sorted(),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("al2", "al3"),
|
||||
db
|
||||
.libraryDao()
|
||||
.albumCreditAlbumIds(1, "a2")
|
||||
.first()
|
||||
.sorted(),
|
||||
)
|
||||
assertEquals(
|
||||
listOf("al1", "al2", "al3"),
|
||||
db
|
||||
.libraryDao()
|
||||
.albumsForArtist(1, "a1")
|
||||
.first()
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyArtistConsumerAgreesOnTheCreditedSet() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest"), artist("a3", "Track Only")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "Album Artist"))),
|
||||
album("al2").copy(artistId = "a2", artists = listOf(ArtistRef("a2", "Guest"))),
|
||||
),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
|
||||
playCount = 1,
|
||||
played = 100,
|
||||
),
|
||||
song("s2").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a2", "Guest"), ArtistRef("a3", "Track Only")),
|
||||
),
|
||||
song("s3").copy(
|
||||
albumId = null,
|
||||
artistId = "a3",
|
||||
artists = listOf(ArtistRef("a3", "Track Only")),
|
||||
),
|
||||
song("s4").copy(
|
||||
albumId = "missing",
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a2", "Guest")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(1, "s4", DownloadStatus.Completed))
|
||||
|
||||
for (artistId in listOf("a1", "a2", "a3")) {
|
||||
val artist = db.libraryDao().artistOnce(1, artistId)!!
|
||||
val songIds = db.queueDao().artistSongIds(1, artistId)
|
||||
val albums = db.libraryDao().albumsForArtist(1, artistId).first()
|
||||
val status = db.downloadDao().artistStatus(1, artistId).first()
|
||||
val downloaded =
|
||||
db
|
||||
.downloadDao()
|
||||
.all()
|
||||
.count { it.status == DownloadStatus.Completed && it.songId in songIds }
|
||||
|
||||
assertEquals("$artistId albums", albums.size.toLong(), artist.albumCount)
|
||||
assertEquals("$artistId songs", songIds.size.toLong(), artist.songCount)
|
||||
assertEquals("$artistId length", songIds.size.toLong(), db.queueDao().artistLength(1, artistId))
|
||||
assertEquals("$artistId total", songIds.size.toLong(), status.total)
|
||||
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
||||
}
|
||||
|
||||
val downloadedArtists = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
||||
for (artist in downloadedArtists) {
|
||||
assertEquals(
|
||||
"${artist.id} downloaded albums",
|
||||
db
|
||||
.libraryDao()
|
||||
.albumsForArtistDownloaded(1, artist.id)
|
||||
.first()
|
||||
.size
|
||||
.toLong(),
|
||||
artist.albumCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changingOnlyTheLeadArtistRecomputesTheCreditedCounts() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Lead"), artist("a2", "Ref")),
|
||||
albums =
|
||||
listOf(
|
||||
album("al1").copy(
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a2", "Ref")),
|
||||
),
|
||||
),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a2", "Ref")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.albumCount)
|
||||
|
||||
source.albums = listOf(album("al1").copy(artistId = "a2", artists = listOf(ArtistRef("a2", "Ref"))))
|
||||
source.songs =
|
||||
listOf(
|
||||
song("s1").copy(
|
||||
albumId = "al1",
|
||||
artistId = "a2",
|
||||
artists = listOf(ArtistRef("a2", "Ref")),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
|
||||
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.albumCount)
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
|
||||
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.albumCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
||||
runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||
val loggingDb =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||
.build()
|
||||
try {
|
||||
loggingDb.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
)
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1", "Artist")),
|
||||
albums = listOf(album("al1")),
|
||||
songs = listOf(song("s1")),
|
||||
)
|
||||
|
||||
SyncService(loggingDb, source).sync()
|
||||
val firstSyncExecutions = synchronized(executions) { executions.toList() }
|
||||
assertTrue(firstSyncExecutions.any { it.sql.contains("credited_albums") })
|
||||
assertTrue(firstSyncExecutions.any { it.sql.contains("UPDATE albums SET playCount") })
|
||||
|
||||
synchronized(executions) { executions.clear() }
|
||||
SyncService(loggingDb, source).sync()
|
||||
|
||||
val secondSyncExecutions = synchronized(executions) { executions.toList() }
|
||||
assertTrue(secondSyncExecutions.none { it.sql.contains("credited_albums") })
|
||||
assertTrue(secondSyncExecutions.none { it.sql.contains("UPDATE albums SET playCount") })
|
||||
} finally {
|
||||
loggingDb.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun totalChanges(): Long =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||
|
||||
@@ -70,6 +70,20 @@ class MediaItemTest {
|
||||
assertEquals(gain, item.replayGain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesItsGainContext() {
|
||||
val item =
|
||||
queueMediaItem(
|
||||
song.copy(albumContext = true),
|
||||
local = null,
|
||||
stream = "http://server/stream?s=s1",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
assertEquals(true, item.albumContext())
|
||||
assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anItemWithoutGainReadsBackAsNoGain() {
|
||||
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
||||
|
||||
@@ -479,6 +479,296 @@ class PlaybackControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumTracksAndQueuedSinglesCarryDifferentGainContexts() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await { handle.items.any { it.id == "s1" } }
|
||||
assertTrue(handle.items.first { it.id == "s1" }.albumContext)
|
||||
|
||||
controller.playNext(1, QueueKind.Song, "x1")
|
||||
await { handle.items.any { it.id == "x1" } }
|
||||
|
||||
assertFalse(handle.items.first { it.id == "x1" }.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedAlbumsCarryAlbumGain() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await { handle.items.any { it.id == "s1" } }
|
||||
|
||||
controller.playNext(1, QueueKind.Album, "al2")
|
||||
await { handle.items.any { it.id == "x1" } }
|
||||
|
||||
assertTrue(handle.items.first { it.id == "x1" }.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledAlbumTracksKeepAlbumContext() {
|
||||
seedShuffledQueue(seed = 5L)
|
||||
|
||||
assertTrue(handle.items.isNotEmpty())
|
||||
assertTrue(handle.items.all { it.albumContext })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAnAlbumTrackKeepsItsAlbumGainContext() {
|
||||
seedAlbum(60, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await { handle.items.any { it.id == "s1" } }
|
||||
|
||||
runBlocking { controller.move(0, 40) }
|
||||
await { controller.state.value.position == 40L }
|
||||
|
||||
assertTrue(handle.items.first { it.id == "s1" }.albumContext)
|
||||
val (entry, _) = runBlocking { queues.snapshot().locate(40)!! }
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals("al1", entry.refId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undoingARemovalRestoresTheTracksAlbumGainContext() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await { handle.items.any { it.id == "s2" } }
|
||||
|
||||
runBlocking { controller.removeAt(1) }
|
||||
await { handle.items.none { it.id == "s2" } }
|
||||
|
||||
runBlocking { controller.undo() }
|
||||
await { handle.items.any { it.id == "s2" } }
|
||||
|
||||
assertTrue(handle.items.first { it.id == "s2" }.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAContextTrackAcrossUpNextKeepsPlayerAndQueueInSync() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { handle.items.any { it.id == "x1" } }
|
||||
|
||||
runBlocking { controller.move(0, 3) }
|
||||
await { controller.state.value.position == 2L }
|
||||
|
||||
val expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
assertEquals(expected, handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"s1",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextDragsWithAnUpNextBlockKeepPlayerAndQueueInSync() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
seedSong("x2", "al2")
|
||||
|
||||
val pairs =
|
||||
listOf(
|
||||
0L to 3L,
|
||||
3L to 0L,
|
||||
0L to 6L,
|
||||
6L to 0L,
|
||||
3L to 4L,
|
||||
4L to 3L,
|
||||
4L to 6L,
|
||||
6L to 4L,
|
||||
1L to 2L,
|
||||
2L to 1L,
|
||||
)
|
||||
|
||||
for ((from, to) in pairs) {
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await { handle.items.map { it.id } == listOf("s1", "s2", "s3", "s4", "s5") }
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
controller.addToQueue(1, QueueKind.Song, "x2")
|
||||
await { handle.items.map { it.id } == listOf("s1", "x1", "x2", "s2", "s3", "s4", "s5") }
|
||||
|
||||
runBlocking { controller.move(from, to) }
|
||||
|
||||
val expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
assertEquals(expected, handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"s1",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(expected.indexOf("s1").toLong(), controller.state.value.position)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draggingWithinTheContextRunsAroundTheBlockKeepsPlayerAndQueueInSync() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 2)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s3"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
|
||||
runBlocking { controller.move(0, 1) }
|
||||
|
||||
var expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
assertEquals(expected, handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"s3",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(expected.indexOf("s3").toLong(), controller.state.value.position)
|
||||
|
||||
runBlocking { controller.move(4, 5) }
|
||||
|
||||
expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
assertEquals(expected, handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"s3",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(expected.indexOf("s3").toLong(), controller.state.value.position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingThePlayingUpNextTrackInTheBlockKeepsPlayerAndQueueInSync() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
seedSong("x2", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
controller.addToQueue(1, QueueKind.Song, "x2")
|
||||
await { handle.items.map { it.id } == listOf("s1", "x1", "x2", "s2", "s3", "s4", "s5") }
|
||||
controller.next()
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "x1"
|
||||
}
|
||||
|
||||
runBlocking { controller.move(1, 2) }
|
||||
|
||||
val expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
assertEquals(expected, handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"x1",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(expected.indexOf("x1").toLong(), controller.state.value.position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draggingAContextTrackWhileAnUpNextTrackPlaysKeepsPlayerAndQueueInSync() {
|
||||
seedAlbum(5, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s1"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
|
||||
|
||||
controller.next()
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "x1"
|
||||
}
|
||||
|
||||
runBlocking { controller.move(0, 6) }
|
||||
|
||||
val expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
assertEquals(expected, handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"x1",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
assertEquals(expected.indexOf("x1").toLong(), controller.state.value.position)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAContextTrackAcrossUpNextFarFromTheWindowKeepsPlayerAndQueueInSync() {
|
||||
seedAlbum(60, sourceId = 1)
|
||||
seedSong("x1", "al2")
|
||||
|
||||
controller.playAlbum(1, "al1", 30)
|
||||
await {
|
||||
controller.state.value.item
|
||||
?.id == "s31"
|
||||
}
|
||||
controller.addToQueue(1, QueueKind.Song, "x1")
|
||||
await { handle.items.any { it.id == "x1" } }
|
||||
|
||||
runBlocking { controller.move(0, 59) }
|
||||
|
||||
val expected =
|
||||
runBlocking {
|
||||
val snapshot = queues.snapshot()
|
||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||
}
|
||||
val current = expected.indexOf("s31")
|
||||
await { controller.state.value.position == current.toLong() }
|
||||
|
||||
val first = (current - 25).coerceAtLeast(0)
|
||||
val last = (current + 25).coerceAtMost(expected.lastIndex)
|
||||
assertEquals(expected.subList(first, last + 1), handle.items.map { it.id })
|
||||
assertEquals(
|
||||
"s31",
|
||||
controller.state.value.item
|
||||
?.id,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
@@ -1268,6 +1558,25 @@ class PlaybackControllerTest {
|
||||
@Test
|
||||
fun movingThePlayingContextTrackStillResolvesAnAlbumTitle() {
|
||||
seedAlbum(3, sourceId = 1)
|
||||
runBlocking {
|
||||
db.libraryDao().upsertAlbums(
|
||||
listOf(
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 3,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
controller.playAlbum(1, "al1", 1)
|
||||
await {
|
||||
|
||||
@@ -35,6 +35,28 @@ class QueueItemTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carriesTheAlbumsGainContextWhenAsked() {
|
||||
val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
)
|
||||
|
||||
val item = AlbumSongItem(song, coverArt = null).toQueueItem(albumContext = true)
|
||||
|
||||
assertEquals(true, item.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithoutGainCarriesNoGain() {
|
||||
val item =
|
||||
|
||||
@@ -38,6 +38,17 @@ class ReplayGainControllerTest {
|
||||
assertEquals(1f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoModeFollowsTheItemsGainContext() {
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Auto, 0f))
|
||||
|
||||
controller.applyItem(item("a", trackGain = -6.0206f, albumGain = -1f))
|
||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||
|
||||
controller.applyItem(item("b", trackGain = -1f, albumGain = -6.0206f, albumContext = true))
|
||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anItemWithoutGainPlaysAtUnity() {
|
||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
||||
@@ -65,6 +76,8 @@ class ReplayGainControllerTest {
|
||||
id: String,
|
||||
trackGain: Float?,
|
||||
trackPeak: Float? = null,
|
||||
albumGain: Float? = null,
|
||||
albumContext: Boolean = false,
|
||||
) = queueMediaItem(
|
||||
QueueItem(
|
||||
id = id,
|
||||
@@ -72,7 +85,8 @@ class ReplayGainControllerTest {
|
||||
artist = null,
|
||||
album = null,
|
||||
coverArtId = null,
|
||||
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
|
||||
replayGain = ReplayGain(trackGain = trackGain, albumGain = albumGain, trackPeak = trackPeak),
|
||||
albumContext = albumContext,
|
||||
),
|
||||
local = null,
|
||||
stream = "http://server/$id",
|
||||
|
||||
@@ -53,6 +53,40 @@ class ReplayGainTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoModeAppliesTheAlbumGainInAnAlbumContext() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
|
||||
ReplayGainSettings(ReplayGainMode.Auto, 0f),
|
||||
albumContext = true,
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoModeAppliesTheTrackGainInAMixedContext() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
replayGainLinearGain(
|
||||
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
|
||||
ReplayGainSettings(ReplayGainMode.Auto, 0f),
|
||||
albumContext = false,
|
||||
),
|
||||
TOLERANCE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoModeFallsBackInEitherContext() {
|
||||
val settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
|
||||
|
||||
assertEquals(0.5f, replayGainLinearGain(ReplayGain(trackGain = -6.0206f), settings, albumContext = true), TOLERANCE)
|
||||
assertEquals(0.5f, replayGainLinearGain(ReplayGain(albumGain = -6.0206f), settings, albumContext = false), TOLERANCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offAppliesUnityGain() {
|
||||
assertEquals(
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -302,11 +303,38 @@ class AppScreenshotTest {
|
||||
|
||||
@Test
|
||||
fun artistDetail() {
|
||||
renderArtistDetail(bioExpanded = false)
|
||||
awaitText("Kid A")
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(ARTIST_NAME_SCRIM_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistDetailExpandedBio() {
|
||||
renderArtistDetail(bioExpanded = true)
|
||||
awaitText("Kid A")
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(ARTIST_NAME_SCRIM_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_bio_expanded.png")
|
||||
}
|
||||
|
||||
private fun renderArtistDetail(bioExpanded: Boolean) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistDetailScreen(
|
||||
artist = Fixtures.artists.first(),
|
||||
albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
|
||||
),
|
||||
bio =
|
||||
"Radiohead are an English rock band formed in Abingdon, Oxfordshire, in 1985. " +
|
||||
"The band consists of Thom Yorke, Jonny Greenwood, Colin Greenwood, Ed O'Brien, " +
|
||||
"and Philip Selway.",
|
||||
bioExpanded = bioExpanded,
|
||||
art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"),
|
||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
@@ -316,11 +344,6 @@ class AppScreenshotTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
awaitText("Kid A")
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(ARTIST_NAME_SCRIM_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -177,8 +178,19 @@ class DemoScreenshotTest {
|
||||
ArtistDetailScreen(
|
||||
artist = demo.artist,
|
||||
artistName = demo.artist.name,
|
||||
isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } },
|
||||
albums = demo.artistAlbums,
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits =
|
||||
demo.artistAlbums.filter { album ->
|
||||
album.artistId == demo.artist.id ||
|
||||
album.artistRefs.any { it.id == demo.artist.id }
|
||||
},
|
||||
appearances =
|
||||
demo.artistAlbums.filterNot { album ->
|
||||
album.artistId == demo.artist.id ||
|
||||
album.artistRefs.any { it.id == demo.artist.id }
|
||||
},
|
||||
),
|
||||
art = demo.coverArt(demo.artist.coverArt, false),
|
||||
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
|
||||
artwork = demo.artwork(demo.artist.coverArt),
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
@@ -20,6 +21,7 @@ import androidx.paging.LoadStates
|
||||
import androidx.paging.PagingData
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -232,6 +235,82 @@ class LibraryScreenTest {
|
||||
assertTrue("the tab that was shown should collect its flow", collected.contains("artists"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingAResultKeepsTheListSearchQuery() {
|
||||
val search = mutableStateOf("xyz")
|
||||
val show = mutableStateOf(true)
|
||||
var opened: Album? = null
|
||||
composeRule.setContent {
|
||||
if (show.value) {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Albums,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = { opened = it },
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
search = search.value,
|
||||
onSearchChange = { search.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onAllNodesWithTag(ALBUM_COVER_TAG)[0].performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals("al-1", opened?.id)
|
||||
assertEquals("xyz", search.value)
|
||||
|
||||
composeRule.runOnIdle { show.value = false }
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backClosesTheListSearchInsteadOfLeavingTheApp() {
|
||||
val search = mutableStateOf("xyz")
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Albums,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.from(albums())) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
search = search.value,
|
||||
onSearchChange = { search.value = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val closeLabel = composeRule.activity.getString(R.string.close_search)
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodesWithContentDescription(closeLabel).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() }
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals("back should clear the list search", "", search.value)
|
||||
composeRule.onNodeWithContentDescription(closeLabel).assertDoesNotExist()
|
||||
assertFalse("back should not leave the app", composeRule.activity.isFinishing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTabRowReflowsInOneDirectionDuringASwitch() {
|
||||
val pagerState = PagerState(currentPage = 1, pageCount = { LibraryTab.entries.size })
|
||||
|
||||
@@ -45,6 +45,29 @@ class ArtistChooserSheetTest {
|
||||
assertEquals(ArtistRef("ar2", "B"), opened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opensTheChosenArtistImmediately() {
|
||||
var opened: ArtistRef? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistChooserSheet(
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
onArtistClick = { opened = it },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("B").performClick()
|
||||
|
||||
assertEquals(ArtistRef("ar2", "B"), opened)
|
||||
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
composeRule.mainClock.autoAdvance = true
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longListsScrollToTheLastArtist() {
|
||||
var opened: ArtistRef? = null
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.library.SongRow
|
||||
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 SongArtistDisplayTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar2",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "Two",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = listOf(ArtistRef("ar1", "One"), ArtistRef("ar2", "Two"), ArtistRef("ar3", "Three")),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun aSongRowListsThePluralArtistsWithTheSingularArtistFirst() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongRow(song = song)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Two · One · Three").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theInfoDialogListsThePluralArtistsWithTheSingularArtistFirst() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Two · One · Three").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AlbumArtistLabelTest {
|
||||
@Test
|
||||
fun creditsBothTheSingularIdAndEveryPluralRef() {
|
||||
val album =
|
||||
album(
|
||||
artistId = "a1",
|
||||
artists = listOf(ArtistRef("a2", "B")),
|
||||
)
|
||||
|
||||
assertTrue(isAlbumArtist(listOf(album), "a1"))
|
||||
assertTrue(isAlbumArtist(listOf(album), "a2"))
|
||||
assertFalse(isAlbumArtist(listOf(album), "a3"))
|
||||
}
|
||||
|
||||
private fun album(
|
||||
artistId: String?,
|
||||
artists: List<ArtistRef>,
|
||||
) = Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = artistId,
|
||||
name = "Album",
|
||||
albumArtist = "Various Artists",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
artists = artists,
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
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.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
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
|
||||
@@ -24,21 +41,32 @@ class ArtistDetailScreenTest {
|
||||
private fun render(
|
||||
artist: Artist?,
|
||||
artistName: String,
|
||||
isAlbumArtist: Boolean? = null,
|
||||
albums: ArtistAlbums = ArtistAlbums(),
|
||||
albumsLoaded: Boolean = true,
|
||||
bio: String? = null,
|
||||
bioLoading: Boolean = false,
|
||||
bioUnavailable: Boolean = false,
|
||||
bioPending: Boolean = false,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
SubtracksTheme {
|
||||
ArtistDetailScreen(
|
||||
artist = artist,
|
||||
artistName = artistName,
|
||||
isAlbumArtist = isAlbumArtist,
|
||||
albums = emptyList(),
|
||||
albums = albums,
|
||||
albumsLoaded = albumsLoaded,
|
||||
bio = bio,
|
||||
bioLoading = bioLoading,
|
||||
bioUnavailable = bioUnavailable,
|
||||
bioPending = bioPending,
|
||||
bioExpanded = expanded,
|
||||
art = null,
|
||||
coverArt = { _, _ -> null },
|
||||
onBack = {},
|
||||
onAlbumClick = {},
|
||||
onExpandBio = { expanded = true },
|
||||
onCollapseBio = { expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -46,10 +74,14 @@ class ArtistDetailScreenTest {
|
||||
|
||||
@Test
|
||||
fun aCreditedTrackArtistShowsItsDerivedNameAndIsLabelledArtist() {
|
||||
render(artist = null, artistName = "Guest", isAlbumArtist = false)
|
||||
render(
|
||||
artist = null,
|
||||
artistName = "Guest",
|
||||
albums = ArtistAlbums(appearances = listOf(album("al-1", "Guest Album"))),
|
||||
)
|
||||
|
||||
composeRule.onAllNodesWithText("Guest")[0].assertExists()
|
||||
composeRule.onNodeWithText("Artist").assertExists()
|
||||
composeRule.onNodeWithText("Artist · 1 album").assertExists()
|
||||
composeRule.onAllNodesWithContentDescription("More options").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@@ -58,13 +90,36 @@ class ArtistDetailScreenTest {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
isAlbumArtist = true,
|
||||
albums = ArtistAlbums(albumCredits = listOf(album("al-1", "Kid A"))),
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Album artist").assertExists()
|
||||
composeRule.onNodeWithText("Album artist · 1 album").assertExists()
|
||||
composeRule.onNodeWithContentDescription("More options").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedCreditsShowAlbumCreditsAndAppearancesInSeparateSections() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 2, starred = null),
|
||||
artistName = "Radiohead",
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits = listOf(album("al-1", "Kid A")),
|
||||
appearances = listOf(album("al-2", "Guest Album")),
|
||||
),
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Album artist · 2 albums").assertExists()
|
||||
|
||||
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("ALBUMS"))
|
||||
composeRule.onNodeWithText("ALBUMS").assertExists()
|
||||
composeRule.onNodeWithText("Kid A").assertExists()
|
||||
|
||||
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("APPEARS ON"))
|
||||
composeRule.onNodeWithText("APPEARS ON").assertExists()
|
||||
composeRule.onNodeWithText("Guest Album").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTypeAndCountStayBlankUntilTheAlbumsLoad() {
|
||||
render(
|
||||
@@ -73,8 +128,223 @@ class ArtistDetailScreenTest {
|
||||
albumsLoaded = false,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Album artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("0 albums").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist · 0 albums").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Album artist · 0 albums").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aStoredBioShowsImmediatelyEvenWhileItRefreshes() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bio = "A stored biography.",
|
||||
bioLoading = true,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("A stored biography.").assertExists()
|
||||
composeRule.onNodeWithText("Loading artist bio…").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingBioShowsTheLoadingStateFirst() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bioLoading = true,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Loading artist bio…").assertExists()
|
||||
composeRule.onNodeWithText("No artist bio available.").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theBioAreaWaitsForTheStoredValueBeforeChoosingAText() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bioLoading = true,
|
||||
bioPending = true,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Loading artist bio…").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("No artist bio available.").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist bio unavailable.").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRefreshIsNotMistakenForAnArtistWithoutABio() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bioUnavailable = true,
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Artist bio unavailable.").assertExists()
|
||||
composeRule.onNodeWithText("No artist bio available.").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingBioShowsTheEmptyStateAfterTheRefresh() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("No artist bio available.").assertExists()
|
||||
composeRule.onNodeWithText("Loading artist bio…").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aShortBioDoesNotOfferMore() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bio = "A short biography.",
|
||||
)
|
||||
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aLongBioExpandsWhenMoreIsTapped() {
|
||||
render(
|
||||
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
|
||||
artistName = "Radiohead",
|
||||
bio =
|
||||
"Radiohead are an English rock band formed in Abingdon, Oxfordshire, in 1985. " +
|
||||
"The band consists of Thom Yorke, Jonny Greenwood, Colin Greenwood, Ed O'Brien, " +
|
||||
"and Philip Selway.",
|
||||
)
|
||||
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).performScrollTo().assertExists()
|
||||
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_LESS_TAG).performScrollTo().assertExists()
|
||||
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_LESS_TAG).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_LESS_TAG).assertDoesNotExist()
|
||||
composeRule.onNodeWithTag(ARTIST_BIO_MORE_TAG).performScrollTo().assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCollapsedSecondLineContinuesWhereTheFirstLineEnds() {
|
||||
val bio =
|
||||
"Radiohead are an English rock band formed in Abingdon, Oxfordshire, in 1985. " +
|
||||
"The band consists of Thom Yorke, Jonny Greenwood, Colin Greenwood, Ed O'Brien, " +
|
||||
"and Philip Selway."
|
||||
var result: BioLines? = null
|
||||
composeRule.setContent {
|
||||
val measurer = rememberTextMeasurer()
|
||||
result = splitBioCollapsed(measurer, bio, TextStyle(fontSize = 14.sp), 400, 0, 0, "More")
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val lines = requireNotNull(result)
|
||||
assertTrue(lines.more)
|
||||
val firstWords =
|
||||
lines.lines[0]
|
||||
.text
|
||||
.split(' ')
|
||||
.filter { it.isNotEmpty() }
|
||||
val next = bio.split(' ').filter { it.isNotEmpty() }[firstWords.size]
|
||||
assertTrue(
|
||||
"expected the second line to continue with $next, was ${lines.lines[1].text}",
|
||||
lines.lines[1].text.startsWith(next),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCollapsedPreviewCanShowThreeLines() {
|
||||
val bio = List(6) { "This is a sufficiently long biography sentence." }.joinToString(" ")
|
||||
var result: BioLines? = null
|
||||
composeRule.setContent {
|
||||
val measurer = rememberTextMeasurer()
|
||||
result =
|
||||
splitBioCollapsed(
|
||||
measurer,
|
||||
bio,
|
||||
TextStyle(fontSize = 14.sp),
|
||||
400,
|
||||
0,
|
||||
0,
|
||||
"More",
|
||||
maxLines = 3,
|
||||
)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val lines = requireNotNull(result)
|
||||
assertEquals(3, lines.lines.size)
|
||||
assertTrue(lines.more)
|
||||
assertTrue(
|
||||
lines.lines
|
||||
.last()
|
||||
.text
|
||||
.endsWith("More"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOversizedFirstWordIsStillShown() {
|
||||
var result: BioLines? = null
|
||||
composeRule.setContent {
|
||||
val measurer = rememberTextMeasurer()
|
||||
result =
|
||||
splitBioCollapsed(
|
||||
measurer,
|
||||
"Supercalifragilisticexpialidocious and the rest",
|
||||
TextStyle(fontSize = 14.sp),
|
||||
20,
|
||||
0,
|
||||
0,
|
||||
"More",
|
||||
)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val lines = requireNotNull(result)
|
||||
assertTrue(lines.more)
|
||||
assertTrue(
|
||||
lines.lines
|
||||
.first()
|
||||
.text
|
||||
.isNotBlank(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceLineAndParagraphBreaksArePreserved() {
|
||||
var lineBreak: List<String>? = null
|
||||
var paragraphBreak: List<String>? = null
|
||||
composeRule.setContent {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = TextStyle(fontSize = 14.sp)
|
||||
lineBreak = splitBio(measurer, "First line.\nSecond line.", style, 400, 0, 2)
|
||||
paragraphBreak = splitBio(measurer, "First paragraph.\n\nSecond paragraph.", style, 400, 0, 2)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(listOf("First line.", "Second line."), lineBreak)
|
||||
assertEquals(listOf("First paragraph.", "", "Second paragraph."), paragraphBreak)
|
||||
}
|
||||
|
||||
private fun album(
|
||||
id: String,
|
||||
name: String,
|
||||
) = Album(
|
||||
sourceId = 1,
|
||||
id = id,
|
||||
artistId = "ar-1",
|
||||
name = name,
|
||||
albumArtist = "Radiohead",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SongArtistSubtitleTest {
|
||||
@Test
|
||||
fun pluralArtistsAreJoinedWithDots() {
|
||||
assertEquals("One · Two", song(artist = null, artists = listOf(ArtistRef("1", "One"), ArtistRef("2", "Two"))).artistSubtitle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singularArtistIsListedFirstWhenItIsAlsoPlural() {
|
||||
assertEquals(
|
||||
"Two · One · Three",
|
||||
song(artist = "Two", artists = listOf(ArtistRef("1", "One"), ArtistRef("2", "Two"), ArtistRef("3", "Three")))
|
||||
.artistSubtitle(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singularArtistIsUsedWhenNoPluralArtistsExist() {
|
||||
assertEquals("One", song(artist = "One", artists = emptyList()).artistSubtitle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noArtistGivesNoSubtitle() {
|
||||
assertNull(song(artist = null, artists = emptyList()).artistSubtitle())
|
||||
}
|
||||
|
||||
private fun song(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
) = Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = artist,
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = artists,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
package com.subtracks.ui.search
|
||||
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -84,6 +92,51 @@ class SearchScreenTest {
|
||||
composeRule.onNodeWithText("No results", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typingKeepsTheCursorAtTheEndAfterTheFieldIsRecreated() {
|
||||
var query by mutableStateOf("road")
|
||||
var show by mutableStateOf(true)
|
||||
composeRule.setContent {
|
||||
if (show) {
|
||||
SubtracksTheme {
|
||||
SearchScreen(query = query, onQueryChange = { query = it }, results = SearchResults())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNode(hasSetTextAction()).performTextInput("s")
|
||||
assertEquals("roads", query)
|
||||
|
||||
composeRule.runOnIdle { show = false }
|
||||
composeRule.runOnIdle { show = true }
|
||||
composeRule.onNode(hasSetTextAction()).performTextInput("!")
|
||||
assertEquals("roads!", query)
|
||||
|
||||
composeRule.runOnIdle { composeRule.activity.currentFocus?.clearFocus() }
|
||||
composeRule.runOnIdle { show = false }
|
||||
composeRule.waitForIdle()
|
||||
// performTextInput opens the IME; close it after the field is gone so later tests in the JVM
|
||||
// do not inherit a visible keyboard.
|
||||
composeRule.activity
|
||||
.getSystemService(InputMethodManager::class.java)
|
||||
.hideSoftInputFromWindow(composeRule.activity.window.decorView.windowToken, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoringTheScreenKeepsFocusOnTheQuery() {
|
||||
val restorationTester = StateRestorationTester(composeRule)
|
||||
restorationTester.setContent {
|
||||
SubtracksTheme {
|
||||
SearchScreen(query = "road", onQueryChange = {}, results = SearchResults())
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNode(hasSetTextAction()).assertIsFocused()
|
||||
restorationTester.emulateSavedInstanceStateRestore()
|
||||
|
||||
composeRule.onNode(hasSetTextAction()).assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idsSharedAcrossTypesDoNotCollide() {
|
||||
composeRule.setContent {
|
||||
|
||||
@@ -19,6 +19,7 @@ class ReplayGainControlsTest {
|
||||
assertEquals("Off", replayGainModeLabel(resources, ReplayGainMode.Off))
|
||||
assertEquals("Track gain", replayGainModeLabel(resources, ReplayGainMode.Track))
|
||||
assertEquals("Album gain", replayGainModeLabel(resources, ReplayGainMode.Album))
|
||||
assertEquals("Auto", replayGainModeLabel(resources, ReplayGainMode.Auto))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -98,7 +98,7 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
|
||||
|
||||
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
|
||||
|
||||
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated.
|
||||
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/auto/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated. Auto is the default and selects album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues; the choice is carried on each media item because the audio thread cannot query the queue.
|
||||
|
||||
## Data model
|
||||
|
||||
|
||||
Reference in New Issue
Block a user