Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd29f75e6 | ||
|
|
d7a3a41ab2 | ||
|
|
05e318f9ab | ||
|
|
e8fb0ac564 | ||
|
|
7379f3d93e | ||
|
|
f9151843a6 | ||
|
|
2f95a89682 | ||
|
|
319ff4b645 | ||
|
|
d8ac7fefc7 | ||
|
|
f46f43b6cd | ||
|
|
8052e1db76 | ||
|
|
edc8782302 | ||
|
|
3ffe38d988 | ||
|
|
b76328fc26 | ||
|
|
9feaf54cd2 | ||
|
|
72e1b61bff | ||
|
|
bb3abe39b6 | ||
|
|
be08e50314 | ||
|
|
b9a3ae5ed0 | ||
|
|
47787182d0 | ||
|
|
aca2bd4be2 | ||
|
|
677596c21b | ||
|
|
1dbfb027fe | ||
|
|
8765c5d37b | ||
|
|
2da0d61d4c | ||
|
|
2c6af4b126 | ||
|
|
61774a19ca | ||
|
|
0602f835dc | ||
|
|
45fc169c42 | ||
|
|
e487d2e0b1 | ||
|
|
6f1e1612e1 | ||
|
|
185aaabbc5 | ||
|
|
c1a01b9a47 | ||
|
|
fbd4c0c4fe | ||
|
|
c56a6d4f83 | ||
|
|
5de5b618d1 | ||
|
|
81d45ad0ad | ||
|
|
5583c9c1d6 | ||
|
|
11a304e7ba | ||
|
|
c8de39acb9 | ||
|
|
77e3fa87f1 | ||
|
|
0a5b3512da | ||
|
|
783d7f2ebc | ||
|
|
18118492c7 | ||
|
|
0d1118011f | ||
|
|
6e60ff6183 | ||
|
|
4a85b23383 |
@@ -85,7 +85,9 @@ jobs:
|
||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check release notes exist
|
||||
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
run: |
|
||||
test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
test -f "docs/releases/$GITHUB_REF_NAME.md"
|
||||
- name: Write Play service account
|
||||
if: steps.stable.outputs.value == 'true'
|
||||
env:
|
||||
@@ -123,7 +125,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
|
||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||
args+=(--prerelease)
|
||||
fi
|
||||
|
||||
@@ -37,6 +37,7 @@ See [signing/README.md](signing/README.md) for the fingerprints and the commands
|
||||
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
||||
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
||||
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
|
||||
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId = "com.subtracks"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 13
|
||||
versionName = "3.0.0"
|
||||
versionCode = 14
|
||||
versionName = "3.1.0"
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.addCallback(SqliteLifecycleLogging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -114,6 +114,16 @@ class LoggedMigration(
|
||||
}
|
||||
}
|
||||
|
||||
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
optimizeArtistSearchTriggers(connection)
|
||||
}
|
||||
|
||||
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
|
||||
}
|
||||
}
|
||||
|
||||
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
Log.i("sql", "created fresh database")
|
||||
|
||||
@@ -162,12 +162,15 @@ interface DownloadDao {
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||
"al.artistId AS albumArtistId, " +
|
||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||
"aar.coverArt AS albumArtistCoverArt " +
|
||||
"FROM song_downloads d " +
|
||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||
)
|
||||
suspend fun artwork(): List<DownloadArtwork>
|
||||
|
||||
|
||||
@@ -50,11 +50,6 @@ internal const val ALBUMS_FILTER =
|
||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
|
||||
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||
@@ -70,6 +65,16 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C
|
||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
private const val ALBUM_ARTIST_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
|
||||
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
|
||||
@@ -124,32 +129,176 @@ internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||
|
||||
// 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 " +
|
||||
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id FROM album_artists " +
|
||||
"SELECT albums.id, album_artists.artistId 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 = artists.id " +
|
||||
"WHERE album_artists.sourceId = :sourceId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM songs " +
|
||||
"SELECT songs.albumId, songs.artistId 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' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId FROM song_artists " +
|
||||
"SELECT songs.albumId, song_artists.artistId 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'"
|
||||
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||
|
||||
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, songs.artistId 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 IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, song_artists.artistId 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 albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
||||
|
||||
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
||||
private const val ARTIST_COVER_CREDITS =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
||||
|
||||
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL"
|
||||
|
||||
private const val ARTIST_COVER_RANKS_TAIL =
|
||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||
"deduped AS (SELECT artistId, coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||
|
||||
private const val ARTIST_COVER_RANKS =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS, " +
|
||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId), " +
|
||||
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
|
||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId), " +
|
||||
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
|
||||
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||
"FROM artists WHERE artists.sourceId = :sourceId) " +
|
||||
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||
|
||||
internal const val ARTIST_COVER_ARTS_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||
|
||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||
"WHERE artistId IN (:artistIds)), " +
|
||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||
"WHERE albums.coverArt IS NOT NULL), " +
|
||||
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId), " +
|
||||
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
|
||||
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
@@ -164,40 +313,10 @@ 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"
|
||||
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"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.
|
||||
@@ -206,11 +325,9 @@ internal const val ARTISTS_SELECT =
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
|
||||
"artists.sortName, artists.credited "
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
||||
|
||||
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
@@ -220,6 +337,15 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s
|
||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
|
||||
private const val SONG_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||
|
||||
internal const val PLAYLISTS_FILTER =
|
||||
"FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
||||
@@ -239,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
|
||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||
") ELSE playlists.songCount END AS songCount, " +
|
||||
"playlists.created, playlists.changed, playlists.duration "
|
||||
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
|
||||
|
||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
@@ -334,10 +460,29 @@ interface LibraryDao {
|
||||
)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||
|
||||
@Query(ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeArtistArt(sourceId: Long)
|
||||
|
||||
@Query(ARTIST_COVER_ARTS_SQL)
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
)
|
||||
|
||||
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun recomputeAggregates(sourceId: Long) {
|
||||
recomputeAlbumPlayData(sourceId)
|
||||
recomputeArtistAggregates(sourceId)
|
||||
recomputeArtistArt(sourceId)
|
||||
}
|
||||
|
||||
@Query(
|
||||
@@ -381,6 +526,12 @@ interface LibraryDao {
|
||||
songId: String,
|
||||
): String?
|
||||
|
||||
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
|
||||
suspend fun creditedArtistIdsForSongs(
|
||||
sourceId: Long,
|
||||
songIds: List<String>,
|
||||
): List<String>
|
||||
|
||||
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
||||
// sync then overwrites with the server's own number.
|
||||
@Transaction
|
||||
@@ -471,22 +622,24 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<Artist>>
|
||||
|
||||
@Query(
|
||||
@@ -503,12 +656,13 @@ interface LibraryDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<AlbumSongItem>>
|
||||
|
||||
@Query(
|
||||
@@ -650,36 +804,40 @@ interface LibraryDao {
|
||||
@RawQuery(observedEntities = [Playlist::class])
|
||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
||||
fun albumsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||
fun albumsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
|
||||
fun albumsByArtist(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
|
||||
fun albumsByArtistReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||
@@ -762,20 +920,22 @@ interface LibraryDao {
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
|
||||
fun artistsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||
fun artistsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||
@@ -42,8 +43,9 @@ internal fun albumsDefaultQuery(
|
||||
search: String,
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery {
|
||||
val order = albumOrder(sort, descending)
|
||||
val order = albumOrder(sort, descending, sortNameMode)
|
||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
}
|
||||
@@ -88,8 +90,9 @@ internal fun artistsDefaultQuery(
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending)
|
||||
val order = artistOrder(sort, descending, sortNameMode)
|
||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
@@ -121,27 +124,82 @@ internal fun playlistsDefaultQuery(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||
}
|
||||
|
||||
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> {
|
||||
"albumArtistSort COLLATE NOCASE"
|
||||
}
|
||||
|
||||
SortNameMode.Name -> {
|
||||
"albumArtist COLLATE NOCASE"
|
||||
}
|
||||
|
||||
SortNameMode.NonLatin -> {
|
||||
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||
}
|
||||
|
||||
private fun albumOrder(
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||
AlbumSort.Name -> {
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
val artist = albumArtistSortKey(mode)
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||
}
|
||||
}
|
||||
|
||||
private fun artistOrder(
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
||||
val name = artistNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
|
||||
@@ -695,6 +695,80 @@ private suspend fun createSearchIndex(
|
||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||
}
|
||||
|
||||
val MIGRATION_35_36 =
|
||||
object : Migration(35, 36) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||
optimizeArtistSearchTriggers(connection)
|
||||
backfillArtistArt(connection)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_36_37 =
|
||||
object : Migration(36, 37) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_37_38 =
|
||||
object : Migration(37, 38) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||
val triggers = mutableListOf<String>()
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
while (statement.step()) triggers += statement.getText(0)
|
||||
}
|
||||
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
|
||||
}
|
||||
|
||||
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
connection.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||
val sourceIds = mutableListOf<Long>()
|
||||
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||
while (statement.step()) sourceIds += statement.getLong(0)
|
||||
}
|
||||
sourceIds.forEach { sourceId ->
|
||||
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> =
|
||||
arrayOf(
|
||||
MIGRATION_1_2,
|
||||
@@ -731,4 +805,7 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_32_33,
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
MIGRATION_35_36,
|
||||
MIGRATION_36_37,
|
||||
MIGRATION_37_38,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.model.SongSearch
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.StringListConverter
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
|
||||
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 35,
|
||||
version = 38,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
DownloadErrorConverter::class,
|
||||
PendingActionKindConverter::class,
|
||||
ArtistRefsConverter::class,
|
||||
StringListConverter::class,
|
||||
)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
||||
coverArt: String,
|
||||
thumbnail: Boolean,
|
||||
): String = "$sourceId:$coverArt:$thumbnail"
|
||||
|
||||
fun artistImageKey(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String = "$sourceId:artist-image:$artistId"
|
||||
|
||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
internal fun fractionOf(
|
||||
bytes: Long,
|
||||
total: Long,
|
||||
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
|
||||
enum class DownloadList { Album, Playlist, Artist }
|
||||
|
||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||
@@ -99,17 +104,19 @@ data class DownloadedSong(
|
||||
val songSort: String = title,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
data class DownloadArtwork(
|
||||
val sourceId: Long,
|
||||
val albumId: String?,
|
||||
val artistId: String?,
|
||||
val albumArtistId: String?,
|
||||
val albumRow: String?,
|
||||
val artistRow: String?,
|
||||
val albumCoverArt: String?,
|
||||
val artistCoverArt: String?,
|
||||
val albumArtistCoverArt: String?,
|
||||
) {
|
||||
val hasMissingLibraryRow: Boolean
|
||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||
|
||||
@@ -59,6 +59,7 @@ data class ArtistRef(
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(
|
||||
name = "index_artists_albumCount",
|
||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||
@@ -88,6 +89,9 @@ data class Artist(
|
||||
val albumCount: Long,
|
||||
val starred: Long?,
|
||||
val coverArt: String? = null,
|
||||
val artistImageUrl: String? = null,
|
||||
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
|
||||
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||
@@ -128,7 +132,9 @@ data class ArtistBio(
|
||||
Index("sourceId"),
|
||||
Index("sourceId", "artistId"),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(
|
||||
name = "index_albums_year",
|
||||
value = ["sourceId", "year", "sortName", "id"],
|
||||
@@ -248,6 +254,8 @@ data class Playlist(
|
||||
val created: Long,
|
||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||
val owner: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -592,6 +600,16 @@ class ArtistRefsConverter {
|
||||
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||
}
|
||||
|
||||
class StringListConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
|
||||
|
||||
@ColumnTypeConverter
|
||||
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
|
||||
}
|
||||
|
||||
private const val LIST_SEPARATOR = "\u001F"
|
||||
|
||||
object ArtistRefsCodec {
|
||||
fun encode(refs: List<ArtistRef>): String =
|
||||
buildString {
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class SortNameMode { SortName, Name, NonLatin }
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||
@@ -182,6 +184,17 @@ class UserPreferences(
|
||||
|
||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||
|
||||
fun sortNameMode(): Flow<SortNameMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||
}
|
||||
|
||||
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||
|
||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
|
||||
@@ -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.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadError
|
||||
@@ -19,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.DownloadedSong
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -66,6 +68,7 @@ class DownloadRepository(
|
||||
|
||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
@@ -350,6 +353,9 @@ class DownloadRepository(
|
||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||
group.forEach { file(sourceId, it.songId).delete() }
|
||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
|
||||
}
|
||||
}
|
||||
sweep(db.downloadDao().all())
|
||||
}
|
||||
@@ -372,6 +378,7 @@ class DownloadRepository(
|
||||
.mapNotNull { it.engineId }
|
||||
.let(engine::cancel)
|
||||
db.downloadDao().deleteSource(sourceId)
|
||||
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||
dir(sourceId).deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -413,13 +420,14 @@ class DownloadRepository(
|
||||
) {
|
||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
|
||||
val coverArt =
|
||||
(
|
||||
listOfNotNull(
|
||||
album?.coverArt,
|
||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
listOfNotNull(album?.coverArt) +
|
||||
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
).distinct()
|
||||
coverArt.forEach { cover ->
|
||||
storeArtwork(sourceId, cover, thumbnail = false)
|
||||
@@ -427,6 +435,37 @@ class DownloadRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeArtistImage(
|
||||
sourceId: Long,
|
||||
artist: Artist,
|
||||
) {
|
||||
val imageUrl = artist.artistImageUrl
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
val cacheKey = artistImageKey(sourceId, artist.id)
|
||||
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
||||
val attemptKey = "$sourceId:${artist.id}:$imageUrl"
|
||||
if (attemptKey !in failedArtistImages) {
|
||||
val bytes =
|
||||
try {
|
||||
artworkFetcher.fetch(imageUrl)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes != null && bytes.isNotEmpty()) {
|
||||
artworkStore.write(sourceId, cacheKey, bytes)
|
||||
return
|
||||
}
|
||||
failedArtistImages += attemptKey
|
||||
}
|
||||
}
|
||||
artist.coverArt?.let {
|
||||
storeArtwork(sourceId, it, thumbnail = false)
|
||||
storeArtwork(sourceId, it, thumbnail = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
@@ -491,11 +530,15 @@ class DownloadRepository(
|
||||
.mapValues { (sourceId, rows) ->
|
||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||
val covers =
|
||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
||||
playlistCovers[sourceId].orEmpty()
|
||||
covers
|
||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
||||
.toSet()
|
||||
rows.flatMap { row ->
|
||||
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||
} + playlistCovers[sourceId].orEmpty()
|
||||
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||
(
|
||||
covers.flatMap { cover ->
|
||||
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
|
||||
} + artistImages.map { artistImageKey(sourceId, it) }
|
||||
).toSet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +588,7 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -625,6 +669,11 @@ class DownloadRepository(
|
||||
},
|
||||
)
|
||||
if (updated != row) db.downloadDao().upsert(updated)
|
||||
if (updated.status != row.status &&
|
||||
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||
) {
|
||||
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||
}
|
||||
if (updated.status != row.status) {
|
||||
when (updated.status) {
|
||||
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||
@@ -642,9 +691,22 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(row.sourceId, row.songId).delete()
|
||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||
if (row.status == DownloadStatus.Completed) {
|
||||
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||
}
|
||||
Log.w("download", "failed ${row.songId}: $error")
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
songIds: Collection<String>,
|
||||
) {
|
||||
songIds.chunked(500).forEach { songs ->
|
||||
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
|
||||
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePolling() {
|
||||
if (pollJob?.isActive == true) return
|
||||
pollJob =
|
||||
|
||||
@@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val userPreferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
@@ -111,12 +116,18 @@ class LibraryRepository(
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Album>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Artist>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchPlaylists(
|
||||
sourceId: Long,
|
||||
@@ -126,7 +137,10 @@ class LibraryRepository(
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<AlbumSongItem>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||
|
||||
@@ -178,66 +192,68 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,50 +268,52 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Artist>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +442,11 @@ class LibraryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||
|
||||
fun albumDiscs(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicConfig
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
@@ -69,6 +70,9 @@ class SourceRepository(
|
||||
private val _offline = MutableStateFlow(false)
|
||||
val offline: StateFlow<Boolean> = _offline
|
||||
|
||||
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||
val activeSource: StateFlow<Long?> = _activeSource
|
||||
|
||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
@@ -109,6 +113,7 @@ class SourceRepository(
|
||||
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||
}
|
||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||
_activeSource.value = config?.id
|
||||
_quality.value = quality
|
||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||
scope.launch {
|
||||
@@ -143,6 +148,18 @@ class SourceRepository(
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
|
||||
fun artistImage(
|
||||
artistId: String,
|
||||
url: String?,
|
||||
): CoverArtRef? {
|
||||
val current = active ?: return null
|
||||
if (url.isNullOrBlank()) return null
|
||||
val cacheKey = artistImageKey(current.id, artistId)
|
||||
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||
if (_offline.value) return null
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
|
||||
fun networkCoverArt(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
|
||||
@@ -32,6 +32,7 @@ object SubsonicXml {
|
||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||
starred = attrs.dateAttr("starred"),
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||
)
|
||||
|
||||
@@ -128,6 +129,8 @@ object SubsonicXml {
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
changed = attrs.dateAttr("changed") ?: 0L,
|
||||
duration = attrs.longAttr("duration") ?: 0L,
|
||||
owner = attrs.attr("owner").ifEmpty { null },
|
||||
shared = attrs.boolAttr("public"),
|
||||
)
|
||||
|
||||
internal fun playlistSong(
|
||||
@@ -364,6 +367,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||
|
||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||
|
||||
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
|
||||
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||
|
||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||
|
||||
@@ -438,7 +438,7 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
@@ -487,7 +487,8 @@ private val songColumns =
|
||||
"artistSort",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
private val playlistColumns =
|
||||
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
|
||||
|
||||
private fun Song.genreNames(): List<String> =
|
||||
genres
|
||||
@@ -515,7 +516,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
||||
|
||||
private fun Album.values() =
|
||||
listOf<Any?>(
|
||||
@@ -577,7 +578,7 @@ private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.pos
|
||||
|
||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
|
||||
|
||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ fun appModule(
|
||||
scope = get(),
|
||||
)
|
||||
}
|
||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||
single {
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
@@ -238,7 +241,14 @@ class Media3PlayerHandle(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||
mediaMetadata.extras
|
||||
?.getString(EXTRA_ARTISTS)
|
||||
?.let(ArtistRefsCodec::decode)
|
||||
.orEmpty()
|
||||
|
||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||
private const val EXTRA_ARTISTS = "artists"
|
||||
private const val EXTRA_DURATION_MS = "durationMs"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||
@@ -286,6 +296,7 @@ internal fun queueMediaItem(
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
@@ -36,6 +37,7 @@ data class QueueItem(
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
val albumContext: Boolean = false,
|
||||
val artists: List<ArtistRef> = emptyList(),
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.SongItem
|
||||
|
||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
@@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artists = song.artistRefs,
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
|
||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.AlbumInfoDialog
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.ArtistInfoDialog
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.ItemContextMenu
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||
import com.subtracks.ui.components.SongInfoDialog
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.downloads.DownloadsRoute
|
||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val contextMenuHost = remember { ContextMenuHost() }
|
||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||
|
||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
||||
nowPlayingLauncher.openNowPlaying.collect {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost.dismissTransients()
|
||||
infoSong = null
|
||||
infoTarget = null
|
||||
artistChooser = null
|
||||
showingQueue = false
|
||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||
@@ -375,7 +383,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -418,7 +434,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -514,7 +538,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -725,23 +781,69 @@ private fun MainNavigation() {
|
||||
target = target,
|
||||
actions = contextMenuHost.actions,
|
||||
onDismiss = { contextMenuHost.dismiss() },
|
||||
onInfo = { infoSong = it },
|
||||
onInfo = { infoTarget = it },
|
||||
onChooseArtists = { artistChooser = it },
|
||||
)
|
||||
}
|
||||
|
||||
infoSong?.let { song ->
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoSong = null },
|
||||
genres = genres,
|
||||
artistChooser?.let { artists ->
|
||||
ArtistChooserSheet(
|
||||
artists = artists,
|
||||
onArtistClick = { artist ->
|
||||
artistChooser = null
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(artist.id),
|
||||
)
|
||||
},
|
||||
onDismiss = { artistChooser = null },
|
||||
)
|
||||
}
|
||||
|
||||
when (val target = infoTarget) {
|
||||
is MenuTarget.Song -> {
|
||||
val song = target.song
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoTarget = null },
|
||||
genres = genres,
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
AlbumInfoDialog(
|
||||
album = target.album,
|
||||
download = target.download,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
ArtistInfoDialog(
|
||||
artist = target.artist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
PlaylistInfoDialog(
|
||||
playlist = target.playlist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
data class ArtistArt(
|
||||
val image: CoverArtRef? = null,
|
||||
val thumbnail: CoverArtRef? = null,
|
||||
val covers: List<CoverArtRef> = emptyList(),
|
||||
val fallback: ArtistArt? = null,
|
||||
) {
|
||||
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||
}
|
||||
|
||||
fun SourceRepository.artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt =
|
||||
resolveArtistArt(
|
||||
artist = artist,
|
||||
coverArt = this::coverArt,
|
||||
artistImage = this::artistImage,
|
||||
offline = offline.value,
|
||||
forIcon = forIcon,
|
||||
)
|
||||
|
||||
internal fun resolveArtistArt(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistImage: (String, String?) -> CoverArtRef?,
|
||||
offline: Boolean,
|
||||
forIcon: Boolean,
|
||||
): ArtistArt {
|
||||
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||
val grid =
|
||||
ArtistArt(
|
||||
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
|
||||
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||
)
|
||||
val own =
|
||||
artist.coverArt?.let { coverId ->
|
||||
coverArt(coverId, forIcon)?.let { image ->
|
||||
ArtistArt(
|
||||
image = image,
|
||||
thumbnail = if (forIcon) image else coverArt(coverId, true),
|
||||
fallback = grid,
|
||||
)
|
||||
}
|
||||
}
|
||||
return artist.artistImageUrl
|
||||
?.let { artistImage(artist.id, it) }
|
||||
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
|
||||
?: own
|
||||
?: grid
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistIcon(
|
||||
artist: Artist,
|
||||
art: ArtistArt,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||
val showFallback = {
|
||||
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||
}
|
||||
when {
|
||||
shown.image != null -> {
|
||||
CoverArt(
|
||||
ref = shown.image,
|
||||
name = artist.name,
|
||||
modifier = modifier,
|
||||
onError = showFallback,
|
||||
)
|
||||
}
|
||||
|
||||
shown.covers.size >= 2 -> {
|
||||
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistHeroArt(
|
||||
art: ArtistArt,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shown by remember(art) { mutableStateOf(art) }
|
||||
val showFallback = {
|
||||
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||
}
|
||||
when {
|
||||
shown.image != null -> {
|
||||
CoverArt(
|
||||
ref = shown.image,
|
||||
name = name,
|
||||
thumbnailRef = shown.thumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
onError = showFallback,
|
||||
)
|
||||
}
|
||||
|
||||
shown.covers.isNotEmpty() -> {
|
||||
ArtistArtGrid(
|
||||
covers = shown.covers,
|
||||
name = name,
|
||||
singleThumbnail = shown.thumbnail,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(ref = null, name = name, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistArtGrid(
|
||||
covers: List<CoverArtRef>,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
singleThumbnail: CoverArtRef? = null,
|
||||
) {
|
||||
when {
|
||||
covers.size >= 4 -> {
|
||||
Column(modifier) {
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.drop(2).take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 3 -> {
|
||||
Box(modifier) {
|
||||
covers.forEachIndexed { index, ref ->
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = name,
|
||||
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 2 -> {
|
||||
Row(modifier) {
|
||||
covers.forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 1 -> {
|
||||
CoverArt(
|
||||
ref = covers.first(),
|
||||
name = name,
|
||||
thumbnailRef = singleThumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class YRegionShape(
|
||||
private val region: Int,
|
||||
) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val left = rayPoint(size, 150.0)
|
||||
val right = rayPoint(size, 30.0)
|
||||
val top = Offset(size.width / 2f, 0f)
|
||||
val path = Path()
|
||||
when (region) {
|
||||
0 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(right.x, right.y)
|
||||
addClockwise(path, size, right, left)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(left.x, left.y)
|
||||
addClockwise(path, size, left, top)
|
||||
}
|
||||
|
||||
else -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(top.x, top.y)
|
||||
addClockwise(path, size, top, right)
|
||||
}
|
||||
}
|
||||
path.lineTo(center.x, center.y)
|
||||
path.close()
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rayPoint(
|
||||
size: Size,
|
||||
angleDegrees: Double,
|
||||
): Offset {
|
||||
val cx = size.width / 2.0
|
||||
val cy = size.height / 2.0
|
||||
val radians = angleDegrees * PI / 180.0
|
||||
val dx = cos(radians)
|
||||
val dy = sin(radians)
|
||||
val tx =
|
||||
when {
|
||||
dx > 0 -> (size.width - cx) / dx
|
||||
dx < 0 -> -cx / dx
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val ty =
|
||||
when {
|
||||
dy > 0 -> (size.height - cy) / dy
|
||||
dy < 0 -> -cy / dy
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val t = min(tx, ty)
|
||||
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||
}
|
||||
|
||||
private fun addClockwise(
|
||||
path: Path,
|
||||
size: Size,
|
||||
from: Offset,
|
||||
to: Offset,
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val corners =
|
||||
listOf(
|
||||
Offset(0f, 0f),
|
||||
Offset(w, 0f),
|
||||
Offset(w, h),
|
||||
Offset(0f, h),
|
||||
)
|
||||
val fromParam = perimeterParam(from, size)
|
||||
val toParam = perimeterParam(to, size)
|
||||
if (fromParam <= toParam) {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
} else {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
corners
|
||||
.filter { perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
}
|
||||
path.lineTo(to.x, to.y)
|
||||
}
|
||||
|
||||
private fun perimeterParam(
|
||||
point: Offset,
|
||||
size: Size,
|
||||
): Float {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
return when {
|
||||
point.y <= 0f -> point.x
|
||||
point.x >= w -> w + point.y
|
||||
point.y >= h -> w + h + (w - point.x)
|
||||
else -> 2f * w + h + (h - point.y)
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,15 @@ fun CoverArt(
|
||||
square: Boolean = true,
|
||||
elevation: Dp = 0.dp,
|
||||
colorFilter: ColorFilter? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
val refKey = ref?.cacheKey
|
||||
val thumbnailKey = thumbnailRef?.cacheKey
|
||||
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||
val letterScheme =
|
||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||
@@ -115,6 +118,10 @@ fun CoverArt(
|
||||
thumbnailLoaded = thumbnailLoaded,
|
||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||
onThumbnailRatio = { thumbnailRatio = it },
|
||||
onThumbnailError = {
|
||||
thumbnailFailed = true
|
||||
if (failed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = colorFilter,
|
||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||
@@ -131,7 +138,10 @@ fun CoverArt(
|
||||
.ratioOrNull()
|
||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -160,7 +170,10 @@ fun CoverArt(
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = FilterQuality.High,
|
||||
)
|
||||
@@ -179,6 +192,10 @@ fun CoverArt(
|
||||
thumbnailLoaded = thumbnailLoaded,
|
||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||
onThumbnailRatio = { thumbnailRatio = it },
|
||||
onThumbnailError = {
|
||||
thumbnailFailed = true
|
||||
if (failed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
||||
thumbnailLoaded: Boolean,
|
||||
onThumbnailLoaded: () -> Unit,
|
||||
onThumbnailRatio: (Float) -> Unit,
|
||||
onThumbnailError: () -> Unit,
|
||||
contentScale: ContentScale,
|
||||
colorFilter: ColorFilter?,
|
||||
placeholderContentColor: Color?,
|
||||
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||
}
|
||||
},
|
||||
onError = { onThumbnailError() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
|
||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentReady: Boolean = true,
|
||||
) {
|
||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(effectiveArtwork) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberRestorableLazyListState(contentReady)
|
||||
val scope = rememberCoroutineScope()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
|
||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||
|
||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
||||
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
val index = listState.firstVisibleItemIndex
|
||||
@@ -206,6 +213,31 @@ fun HeroDetailScaffold(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||
val state = rememberLazyListState()
|
||||
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||
LaunchedEffect(contentReady) {
|
||||
if (contentReady && pending) {
|
||||
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||
state.scrollToItem(target, offset)
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||
.collect { (currentIndex, currentOffset) ->
|
||||
if (!pending) {
|
||||
index = currentIndex
|
||||
offset = currentOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroSubtitle(
|
||||
text: String,
|
||||
@@ -291,7 +323,11 @@ fun HeroHeader(
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(min = 32.dp)
|
||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClickLabel = stringResource(R.string.open_artist),
|
||||
) { onSubtitleClick() },
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.Cancel
|
||||
@@ -50,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -197,7 +199,8 @@ fun ItemContextMenu(
|
||||
target: MenuTarget,
|
||||
actions: ItemActions,
|
||||
onDismiss: () -> Unit,
|
||||
onInfo: ((SongModel) -> Unit)? = null,
|
||||
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -210,6 +213,11 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceWith(after: () -> Unit) {
|
||||
onDismiss()
|
||||
after()
|
||||
}
|
||||
|
||||
val queueRef = target.queueRef()
|
||||
|
||||
@Composable
|
||||
@@ -256,18 +264,9 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +278,9 @@ fun ItemContextMenu(
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +291,17 @@ fun ItemContextMenu(
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -342,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistItems(
|
||||
artists: List<ArtistRef>,
|
||||
actions: ItemActions,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||
dismiss: (() -> Unit) -> Unit,
|
||||
replaceWith: (() -> Unit) -> Unit,
|
||||
) {
|
||||
val view = actions.viewArtist ?: return
|
||||
val distinct = artists.distinctBy { it.id }
|
||||
when (distinct.size) {
|
||||
0 -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val artist = distinct.first()
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (onChooseArtists == null) {
|
||||
distinct.forEach { artist ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val fallback = stringResource(R.string.resources_filter_artist)
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||
) {
|
||||
replaceWith { onChooseArtists(distinct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadItem(
|
||||
download: SongDownload?,
|
||||
@@ -439,11 +487,19 @@ private fun MenuItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||
trailingContent = trailing,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
@Composable
|
||||
fun AlbumInfoDialog(
|
||||
album: Album,
|
||||
download: ListDownloadStatus?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||
download?.takeIf { it.total > 0 }?.let { status ->
|
||||
InfoRow(
|
||||
stringResource(R.string.status_downloaded),
|
||||
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||
)
|
||||
}
|
||||
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistInfoDialog(
|
||||
artist: Artist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaylistInfoDialog(
|
||||
playlist: Playlist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||
|
||||
@Composable
|
||||
internal fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ArtistRef>.names(): String? =
|
||||
map { it.name }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(ARTIST_SEPARATOR)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun formatDate(
|
||||
context: Context,
|
||||
epochSeconds: Long,
|
||||
): String =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
epochSeconds * 1000,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||
)
|
||||
|
||||
private val INFO_LABEL_WIDTH = 100.dp
|
||||
private val INFO_LABEL_GAP = 12.dp
|
||||
@@ -1,12 +1,28 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
internal fun Song.artistSubtitle(): String? {
|
||||
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
|
||||
|
||||
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
|
||||
|
||||
internal fun artistNames(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
): List<String> {
|
||||
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||
if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() }
|
||||
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
|
||||
}
|
||||
|
||||
internal fun artistSubtitle(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
): String? {
|
||||
val names = artistNames(artist, artists)
|
||||
if (names.isEmpty()) return null
|
||||
val primary = artist?.takeIf { it in names }
|
||||
return buildList {
|
||||
primary?.let(::add)
|
||||
|
||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val multipleArtists = song.artistNames().size > 1
|
||||
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artistSubtitle())
|
||||
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
|
||||
InfoRow(
|
||||
stringResource(
|
||||
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
|
||||
),
|
||||
song.artistSubtitle(),
|
||||
)
|
||||
InfoRow(
|
||||
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
|
||||
song.artistSort.takeIfDifferentFrom(regularArtistSort),
|
||||
)
|
||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(
|
||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val INFO_LABEL_WIDTH = 80.dp
|
||||
|
||||
@Composable
|
||||
private fun downloadStatus(status: DownloadStatus): String =
|
||||
when (status) {
|
||||
|
||||
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -108,6 +110,7 @@ fun HomeListRoute(
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@@ -133,6 +136,7 @@ fun HomeListScreen(
|
||||
genres: List<String> = emptyList(),
|
||||
decades: List<Long> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -191,7 +195,7 @@ fun HomeListScreen(
|
||||
OfflineListKind.Artists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -245,7 +249,7 @@ fun HomeListScreen(
|
||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
|
||||
@Composable
|
||||
private fun ArtistsList(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
info: (Artist) -> String?,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
@@ -460,12 +464,13 @@ private fun ArtistsList(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
art = art,
|
||||
info = info(artist),
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -475,7 +480,7 @@ private fun ArtistsList(
|
||||
@Composable
|
||||
internal fun ArtistListRow(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
info: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -156,6 +158,11 @@ class HomeListViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
val genre = request.genre
|
||||
when {
|
||||
|
||||
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
||||
HomeScreen(
|
||||
feed = feed,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -173,6 +177,7 @@ fun HomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
||||
HomeSection.RecentlyPlayedArtists,
|
||||
feed.recentlyPlayedArtists,
|
||||
coverArt,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
||||
HomeSection.MostPlayedArtists,
|
||||
feed.mostPlayedArtists,
|
||||
coverArt,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
||||
) {
|
||||
homeTitle()
|
||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||
songList(
|
||||
key = "downloaded-songs",
|
||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
||||
|
||||
private fun LazyListScope.downloadedArtistRow(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
|
||||
section: HomeSection,
|
||||
artists: List<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||
) {
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
||||
@Composable
|
||||
private fun ArtistTile(
|
||||
artist: Artist,
|
||||
ref: CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -127,6 +129,11 @@ class HomeViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun playStarred(song: Song) {
|
||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
@@ -67,9 +67,9 @@ fun AlbumDetailRoute(
|
||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
@@ -215,6 +215,7 @@ fun AlbumDetailScreen(
|
||||
artwork = artwork,
|
||||
title = album?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.isNotEmpty(),
|
||||
header = { controlsModifier, topInset ->
|
||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||
HeroHeader(
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
val album: StateFlow<Album?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<List<Song>> =
|
||||
val songs: StateFlow<List<Song>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
val discs: StateFlow<List<Disc>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
@@ -54,18 +56,25 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
@@ -82,7 +91,9 @@ 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.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -95,6 +106,8 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistHeroArt
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
@@ -121,7 +134,11 @@ import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
private val ART_HEIGHT = 420.dp
|
||||
private val TITLE_INSET = 16.dp
|
||||
@@ -134,8 +151,10 @@ 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_ACTION_OFFSET = 16.dp
|
||||
private val BIO_ACTION_END_OFFSET = 8.dp
|
||||
private val BIO_TOP_GAP = 8.dp
|
||||
private val BIO_BOTTOM_GAP = 8.dp
|
||||
|
||||
internal fun estimateScrollPx(
|
||||
index: Int,
|
||||
@@ -176,9 +195,9 @@ fun ArtistDetailRoute(
|
||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||
val bioSnapshots =
|
||||
remember(viewModel) {
|
||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||
@@ -193,6 +212,7 @@ fun ArtistDetailRoute(
|
||||
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
@@ -232,6 +252,7 @@ fun ArtistDetailRoute(
|
||||
bioExpanded = bioExpanded,
|
||||
art = art,
|
||||
artThumbnail = artThumbnail,
|
||||
heroArt = heroArt,
|
||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||
coverArt = viewModel::coverArt,
|
||||
onBack = onBack,
|
||||
@@ -271,6 +292,7 @@ fun ArtistDetailScreen(
|
||||
bioExpanded: Boolean = false,
|
||||
art: CoverArtRef?,
|
||||
artThumbnail: CoverArtRef? = null,
|
||||
heroArt: ArtistArt? = null,
|
||||
artwork: ArtworkColors? = null,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
onBack: () -> Unit,
|
||||
@@ -284,7 +306,7 @@ fun ArtistDetailScreen(
|
||||
onCollapseBio: () -> Unit = {},
|
||||
onShuffle: () -> Unit = {},
|
||||
) {
|
||||
val listState = rememberLazyGridState()
|
||||
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val lineHeight =
|
||||
@@ -331,8 +353,8 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
@@ -388,11 +410,9 @@ fun ArtistDetailScreen(
|
||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
CoverArt(
|
||||
ref = art,
|
||||
ArtistHeroArt(
|
||||
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
|
||||
name = displayName,
|
||||
thumbnailRef = artThumbnail,
|
||||
showPlaceholder = art == null,
|
||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||
)
|
||||
if (nameScrimAlpha > 0f) {
|
||||
@@ -470,7 +490,7 @@ fun ArtistDetailScreen(
|
||||
onCollapse = onCollapseBio,
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
bottom = if (hasBio && !bioCollapsed) 8.dp else 0.dp,
|
||||
bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -554,8 +574,32 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The expanded layout narrows only the lines that overlap the action buttons; later lines use the
|
||||
// full width.
|
||||
@Composable
|
||||
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||
val state = rememberLazyGridState()
|
||||
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||
LaunchedEffect(contentReady) {
|
||||
if (contentReady && pending) {
|
||||
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||
state.scrollToItem(target, offset)
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||
.collect { (currentIndex, currentOffset) ->
|
||||
if (!pending) {
|
||||
index = currentIndex
|
||||
offset = currentOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||
@Composable
|
||||
private fun ArtistBio(
|
||||
text: String,
|
||||
@@ -587,10 +631,28 @@ private fun ArtistBio(
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val bioLines =
|
||||
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) {
|
||||
remember(
|
||||
text,
|
||||
fullWidth,
|
||||
reserve,
|
||||
reserveLines,
|
||||
collapsed,
|
||||
expanded,
|
||||
moreText,
|
||||
lessText,
|
||||
) {
|
||||
when {
|
||||
collapsed -> {
|
||||
splitBioCollapsed(measurer, text, style, fullWidth, reserve, reserveLines, moreText, maxLines = 3)
|
||||
splitBioCollapsed(
|
||||
measurer,
|
||||
text,
|
||||
style,
|
||||
fullWidth,
|
||||
reserve,
|
||||
reserveLines,
|
||||
moreText,
|
||||
maxLines = 3,
|
||||
)
|
||||
}
|
||||
|
||||
expanded -> {
|
||||
@@ -626,7 +688,11 @@ private fun ArtistBio(
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(y = -BIO_ACTION_OFFSET)) {
|
||||
Row(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
|
||||
) {
|
||||
ArtistActions(artist, starred, onToggleStar, onMore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -40,23 +38,26 @@ class ArtistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
val artist: StateFlow<Artist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val artistName: Flow<String?> =
|
||||
sourceId.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}
|
||||
val artistName: StateFlow<String?> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val albums: Flow<ArtistAlbums?> =
|
||||
val albums: StateFlow<ArtistAlbums?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.map<ArtistAlbums, ArtistAlbums?> { it }
|
||||
.onStart { emit(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
@@ -68,7 +69,10 @@ class ArtistDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
val artistBio: Flow<ArtistBio?> = sourceId.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||
val artistBio: StateFlow<ArtistBio?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _bioLoading = MutableStateFlow(true)
|
||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||
@@ -136,30 +140,36 @@ class ArtistDetailViewModel(
|
||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||
|
||||
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
artist
|
||||
sourceRepository.activeSource
|
||||
.filterNotNull()
|
||||
.collectLatest { loaded ->
|
||||
val (full, thumbnail) = resolveArt(loaded)
|
||||
_art.value = full
|
||||
_artThumbnail.value = thumbnail
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(
|
||||
libraryRepository.artist(sourceId, artistId),
|
||||
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||
sourceRepository.offline,
|
||||
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||
}.collectLatest { input ->
|
||||
val resolved = resolveArtistArtInput(input)
|
||||
if (resolved == null) {
|
||||
_art.value = null
|
||||
_artThumbnail.value = null
|
||||
_heroArt.value = null
|
||||
return@collectLatest
|
||||
}
|
||||
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||
_heroArt.value = hero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
|
||||
if (artist.coverArt == null) return null to null
|
||||
repeat(3) { attempt ->
|
||||
val full = sourceRepository.coverArt(artist.coverArt)
|
||||
if (full != null) {
|
||||
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
|
||||
}
|
||||
if (attempt < 2) delay(300)
|
||||
}
|
||||
return null to null
|
||||
}
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
@@ -171,3 +181,35 @@ class ArtistDetailViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ArtistArtInput(
|
||||
val artist: Artist?,
|
||||
val artistId: String,
|
||||
val offline: Boolean,
|
||||
val sourceId: Long,
|
||||
val covers: List<String>,
|
||||
)
|
||||
|
||||
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||
val artist = input.artist
|
||||
if (input.covers.isEmpty()) return artist
|
||||
if (artist == null) {
|
||||
return Artist(
|
||||
sourceId = input.sourceId,
|
||||
id = input.artistId,
|
||||
name = "",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArts = input.covers,
|
||||
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||
)
|
||||
}
|
||||
return if (input.offline) {
|
||||
artist.copy(
|
||||
coverArts = input.covers,
|
||||
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||
)
|
||||
} else {
|
||||
artist.copy(coverArts = input.covers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.ListDownloadIndicator
|
||||
@@ -55,6 +56,7 @@ fun ArtistsContent(
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -82,7 +84,8 @@ fun ArtistsContent(
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
||||
val art = artistArt(artist)
|
||||
PrefetchArtworkSeeds(art.primary)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -104,9 +107,9 @@ fun ArtistsContent(
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
@@ -117,7 +120,7 @@ fun ArtistsContent(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = {
|
||||
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
|
||||
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
|
||||
},
|
||||
),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -43,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -71,7 +74,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
@@ -94,12 +96,14 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.PagingData
|
||||
@@ -120,6 +124,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
@@ -308,6 +313,7 @@ fun LibraryRoute(
|
||||
artists = viewModel.artists,
|
||||
playlists = viewModel.playlists,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
@@ -396,6 +402,7 @@ fun LibraryScreen(
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
onOpenSearch: () -> Unit = {},
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
@@ -539,6 +546,7 @@ fun LibraryScreen(
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
artistArt = artistArt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -588,8 +596,6 @@ fun LibraryScreen(
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
@@ -611,11 +617,29 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
if (!searchActive) {
|
||||
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||
if (offline) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onExitOffline,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||
.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
val searchFab = selectedTab == LibraryTab.Home
|
||||
FloatingActionButton(
|
||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
||||
containerColor = fabContainerColor,
|
||||
contentColor = fabContentColor,
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||
) {
|
||||
@@ -634,11 +658,18 @@ fun LibraryScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
.offset(x = 1.dp, y = 2.5.dp)
|
||||
.size(11.dp)
|
||||
.background(fabContainerColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.background(fabContentColor, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,7 +729,7 @@ private fun ListOptionsSheet(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -820,6 +851,7 @@ private fun ListOptionsSheet(
|
||||
|
||||
private val BOTTOM_CLEARANCE = 80.dp
|
||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||
private val TAB_ICON_SIZE = 24.dp
|
||||
private val TAB_VERTICAL_PADDING = 6.dp
|
||||
private val TOUCH_TARGET = 48.dp
|
||||
@@ -852,13 +884,12 @@ internal fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val page = pagerState.currentPage
|
||||
val fraction = pagerState.currentPageOffsetFraction
|
||||
@@ -890,79 +921,113 @@ internal fun LibraryTabs(
|
||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||
var contentWidth by remember { mutableIntStateOf(0) }
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||
if (viewportWidth == 0) return@LaunchedEffect
|
||||
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||
val target =
|
||||
when {
|
||||
start - leadingGap < scrollState.value -> start - leadingGap
|
||||
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||
}
|
||||
revealed = true
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(headerColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawBehind {
|
||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { viewportWidth = it.width }
|
||||
.horizontalScroll(scrollState)
|
||||
.onSizeChanged { contentWidth = it.width },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawBehind {
|
||||
if (current != null &&
|
||||
indicatorLeft != null &&
|
||||
indicatorRight != null &&
|
||||
indicatorRight > indicatorLeft
|
||||
) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (offline) {
|
||||
IconButton(onClick = onExitOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -211,6 +213,11 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
|
||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
|
||||
artwork = artwork,
|
||||
title = playlist?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.itemCount > 0,
|
||||
header = { controlsModifier, topInset ->
|
||||
HeroHeader(
|
||||
art = coverArt(playlist?.coverArt, false),
|
||||
|
||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
val playlist: StateFlow<Playlist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.playerSurfaceColor
|
||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
Text(
|
||||
text = item.artist ?: item.album.orEmpty(),
|
||||
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
@@ -576,7 +577,10 @@ fun NowPlayingScreen(
|
||||
}
|
||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||
Text(
|
||||
text = state.item?.artist.orEmpty(),
|
||||
text =
|
||||
state.item
|
||||
?.let { artistSubtitle(it.artist, it.artists) }
|
||||
.orEmpty(),
|
||||
style = subtitleStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -106,6 +107,7 @@ fun SearchRoute(
|
||||
if (next != 0) filterMask = next
|
||||
},
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = {
|
||||
@@ -146,6 +148,7 @@ fun SearchScreen(
|
||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -215,7 +218,7 @@ fun SearchScreen(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
|
||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||
}
|
||||
@@ -308,19 +311,20 @@ private fun LazyListScope.albumResults(
|
||||
private fun LazyListScope.artistResults(
|
||||
artists: List<Artist>,
|
||||
header: String,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
) {
|
||||
if (artists.isEmpty()) return
|
||||
resultHeader(header)
|
||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
art = art,
|
||||
info = null,
|
||||
onClick = { onClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import com.subtracks.ui.home.playSongInContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -74,6 +76,11 @@ class SearchViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
|
||||
SyncMode,
|
||||
ReplayGain,
|
||||
ReplayGainPreamp,
|
||||
SortNames,
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -128,6 +130,7 @@ fun SettingsRoute(
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
@@ -143,6 +146,7 @@ fun SettingsRoute(
|
||||
scrobbling = scrobbling,
|
||||
replayGainMode = replayGainMode,
|
||||
replayGainPreamp = replayGainPreamp,
|
||||
sortNameMode = sortNameMode,
|
||||
keepScreenOn = keepScreenOn,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
@@ -158,6 +162,7 @@ fun SettingsRoute(
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||
onSortNameModeChange = viewModel::setSortNameMode,
|
||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
@@ -205,6 +210,7 @@ fun SettingsScreen(
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||
replayGainPreamp: Float = 0f,
|
||||
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -217,6 +223,7 @@ fun SettingsScreen(
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
@@ -348,6 +355,15 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_library_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -603,6 +619,14 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SortNames -> {
|
||||
SortNameModeDialog(
|
||||
selected = sortNameMode,
|
||||
onSelect = onSortNameModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -756,6 +780,52 @@ private fun SyncModeDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val sortNameModeOptions =
|
||||
listOf(SortNameMode.Name, SortNameMode.SortName, SortNameMode.NonLatin)
|
||||
|
||||
private fun sortNameModeLabel(
|
||||
resources: Resources,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name)
|
||||
SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name)
|
||||
SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortNameModeDialog(
|
||||
selected: SortNameMode,
|
||||
onSelect: (SortNameMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.settings_sort_names_prompt),
|
||||
options = sortNameModeOptions.map { it to sortNameModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayGainModeDialog(
|
||||
selected: ReplayGainMode,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -76,6 +77,11 @@ class SettingsViewModel(
|
||||
.replayGainPreampDb()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||
|
||||
val sortNameMode: StateFlow<SortNameMode> =
|
||||
userPreferences
|
||||
.sortNameMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||
|
||||
val keepScreenOn: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.keepScreenOn()
|
||||
@@ -143,4 +149,8 @@ class SettingsViewModel(
|
||||
fun setKeepScreenOn(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||
}
|
||||
|
||||
fun setSortNameMode(mode: SortNameMode) {
|
||||
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_library_name">Library</string>
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||
@@ -96,6 +97,11 @@
|
||||
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
||||
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
||||
<string name="settings_sort_names_title">Sort names</string>
|
||||
<string name="settings_sort_names_prompt">Choose how names are ordered in album and artist lists.</string>
|
||||
<string name="sort_name_mode_sort_name">Use sort names</string>
|
||||
<string name="sort_name_mode_name">Use regular names</string>
|
||||
<string name="sort_name_mode_non_latin">Non-Latin names only</string>
|
||||
<string name="settings_network_name">Network</string>
|
||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||
@@ -191,6 +197,24 @@
|
||||
<string name="info_sample_rate_value">%1$d Hz</string>
|
||||
<string name="info_channels">Channels</string>
|
||||
<string name="info_close">Close</string>
|
||||
<string name="info_artists">Artists</string>
|
||||
<string name="info_artists_sort">Artists sort</string>
|
||||
<string name="info_album_artists">Album artists</string>
|
||||
<string name="info_album_artist_sort">Album artist sort</string>
|
||||
<string name="info_sort_title">Sort title</string>
|
||||
<string name="info_artist_sort">Artist sort</string>
|
||||
<string name="info_sort_name">Sort name</string>
|
||||
<string name="info_albums">Albums</string>
|
||||
<string name="info_songs">Songs</string>
|
||||
<string name="info_plays">Plays</string>
|
||||
<string name="info_last_played">Last played</string>
|
||||
<string name="info_added">Added</string>
|
||||
<string name="info_created">Created</string>
|
||||
<string name="info_updated">Updated</string>
|
||||
<string name="info_owner">Owner</string>
|
||||
<string name="info_shared">Shared</string>
|
||||
<string name="info_length">Length</string>
|
||||
<string name="info_downloaded_count">%1$d of %2$d</string>
|
||||
|
||||
<string name="disc_header">Disc %1$d</string>
|
||||
<string name="disc_track">%1$s: Disc %2$d</string>
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
@@ -332,10 +333,18 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
ALBUM_ORDER_BY_NAME to "index_albums_name",
|
||||
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name",
|
||||
ALBUM_ORDER_BY_ARTIST to "index_albums_artist",
|
||||
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist",
|
||||
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_name",
|
||||
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_name",
|
||||
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_name_plain",
|
||||
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_name_plain",
|
||||
"${albumArtistSortKey(SortNameMode.SortName)}, year, " +
|
||||
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_artist",
|
||||
"${albumArtistSortKey(SortNameMode.SortName)} DESC, year DESC, " +
|
||||
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_artist",
|
||||
"${albumArtistSortKey(SortNameMode.Name)}, year, " +
|
||||
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_artist_plain",
|
||||
"${albumArtistSortKey(SortNameMode.Name)} DESC, year DESC, " +
|
||||
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_artist_plain",
|
||||
ALBUM_ORDER_BY_YEAR to "index_albums_year",
|
||||
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
|
||||
ALBUM_ORDER_BY_ADDED to "index_albums_added",
|
||||
@@ -359,8 +368,10 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
ARTIST_ORDER_BY_NAME to "index_artists_name",
|
||||
ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
|
||||
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||
@@ -548,7 +559,7 @@ class LibraryDaoTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
|
||||
fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
@@ -615,40 +626,241 @@ class LibraryDaoTest {
|
||||
}
|
||||
}
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
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-album").coverArt)
|
||||
assertNull(artists.getValue("ar-track").coverArt)
|
||||
assertNull(artists.getValue("ar-album-ref").coverArt)
|
||||
assertNull(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(listOf("own-album"), artists.getValue("ar-own").coverArts)
|
||||
assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts)
|
||||
assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts)
|
||||
assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts)
|
||||
assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts)
|
||||
assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts)
|
||||
assertEquals(emptyList<String>(), artists.getValue("ar-none").coverArts)
|
||||
assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts)
|
||||
assertEquals(
|
||||
"album-ref-art",
|
||||
listOf("album-ref-art"),
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
||||
.page()
|
||||
.first { it.id == "ar-album-ref" }
|
||||
.coverArt,
|
||||
.coverArts,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
|
||||
fun coverArtFallbackFindsEveryCreditPath() =
|
||||
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=?)"),
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Album", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "track-art"),
|
||||
album(sourceId, "al-2", "Featured", year = 2021, starred = null)
|
||||
.copy(artistId = "ar-other", coverArt = "featured-art"),
|
||||
),
|
||||
)
|
||||
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=?)"),
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s-1", "Song", starred = null)
|
||||
.copy(albumId = "al-1", artistId = "ar-missing"),
|
||||
song(sourceId, "s-2", "Featured", starred = null)
|
||||
.copy(albumId = "al-2", artistId = "ar-other"),
|
||||
),
|
||||
)
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (?, 'al-2', 0, 'ar-featured', 'Featured')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (?, 's-2', 0, 'ar-guest', 'Guest')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
assertNull(dao.artistOnce(sourceId, "ar-missing"))
|
||||
assertNull(dao.artistOnce(sourceId, "ar-featured"))
|
||||
assertNull(dao.artistOnce(sourceId, "ar-guest"))
|
||||
assertEquals(listOf("track-art"), dao.artistCoverArts(sourceId, "ar-missing").first())
|
||||
assertEquals(listOf("featured-art", "track-art"), dao.artistCoverArts(sourceId, "ar-other").first())
|
||||
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-featured").first())
|
||||
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-guest").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistCoverArtsRanksDeduplicatesCapsAndStaysInSource() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val otherSourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-rank", "Rank", albumCount = 0)))
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a-new", "New", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "shared"),
|
||||
album(sourceId, "a-mid", "Mid", year = 2015, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "other"),
|
||||
album(sourceId, "a-old", "Old", year = 2010, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "shared"),
|
||||
album(sourceId, "a-4", "Four", year = 2012, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "c4"),
|
||||
album(sourceId, "a-5", "Five", year = 2011, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "c5"),
|
||||
album(sourceId, "a-6", "Six", year = 2009, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "c6"),
|
||||
album(otherSourceId, "a-leak", "Leak", year = 2025, starred = null)
|
||||
.copy(artistId = "ar-rank", coverArt = "leak"),
|
||||
),
|
||||
)
|
||||
|
||||
val expected = listOf("shared", "other", "c4", "c5")
|
||||
assertEquals(expected, dao.artistCoverArts(sourceId, "ar-rank").first())
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
assertEquals(expected, dao.artistOnce(sourceId, "ar-rank")!!.coverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3)))
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "One", year = 2020, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = "art-1"),
|
||||
album(sourceId, "al-2", "Two", year = 2021, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = "art-2"),
|
||||
album(sourceId, "al-3", "Three", year = 2022, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = "art-3"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
|
||||
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-1"),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
val artist = dao.artistOnce(sourceId, "ar-1")!!
|
||||
assertEquals(listOf("art-3", "art-2", "art-1"), artist.coverArts)
|
||||
assertEquals(listOf("art-1"), artist.downloadedCoverArts)
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
||||
dao.recomputeDownloadedArtistArt(sourceId, listOf("ar-1"))
|
||||
|
||||
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 0)))
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Renamed", albumCount = 0)))
|
||||
|
||||
val hits =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared("SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'Renamed'") {
|
||||
it.step()
|
||||
it.getLong(0)
|
||||
}
|
||||
}
|
||||
assertEquals(1L, hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtDeduplicatesCoversBeforeApplyingTheFourCoverBudget() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 6)))
|
||||
dao.upsertAlbums(
|
||||
listOf("A", "A", "O", "B", "C", "D").mapIndexed { index, cover ->
|
||||
album(sourceId, "al-$index", "Album $index", year = 2021L - index, starred = null)
|
||||
.copy(artistId = "ar-1", coverArt = cover)
|
||||
},
|
||||
)
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
assertEquals(
|
||||
listOf("A", "O", "B", "C"),
|
||||
dao.artistOnce(sourceId, "ar-1")!!.coverArts,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistArtMaterializationUsesTheCreditIndexes() =
|
||||
runTest {
|
||||
val plan = plan(ARTIST_ART_UPDATE_SQL)
|
||||
|
||||
assertTrue(plan, plan.contains("index_albums_sourceId_artistId"))
|
||||
assertTrue(plan, plan.contains("index_album_artists_sourceId_artistId"))
|
||||
assertTrue(plan, plan.contains("index_songs_sourceId_artistId"))
|
||||
assertTrue(plan, plan.contains("index_song_artists_sourceId_artistId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun creditedSongArtistsIncludeTheAlbumArtistAndTrackCredits() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-album", "Album Artist", albumCount = 1),
|
||||
artist(sourceId, "ar-track", "Track Artist", albumCount = 0),
|
||||
artist(sourceId, "ar-guest", "Guest", albumCount = 0),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Album", year = null, starred = null)
|
||||
.copy(artistId = "ar-album"),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "Song", starred = null)
|
||||
.copy(albumId = "al-1", artistId = "ar-track"),
|
||||
),
|
||||
)
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (?, 's1', 0, 'ar-guest', 'Guest')",
|
||||
) { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
setOf("ar-album", "ar-track", "ar-guest"),
|
||||
dao.creditedArtistIdsForSongs(sourceId, listOf("s1")).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -804,7 +1016,12 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "List", created = 0, changed = 0).copy(songCount = 5)))
|
||||
dao.upsertPlaylists(
|
||||
listOf(
|
||||
playlist(sourceId, "pl-1", "List", created = 0, changed = 0)
|
||||
.copy(songCount = 5, owner = "austin", shared = true),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null),
|
||||
@@ -822,6 +1039,8 @@ class LibraryDaoTest {
|
||||
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals(1L, filtered.first().songCount)
|
||||
assertEquals("austin", filtered.first().owner)
|
||||
assertEquals(true, filtered.first().shared)
|
||||
|
||||
assertEquals(
|
||||
5L,
|
||||
@@ -935,7 +1154,7 @@ class LibraryDaoTest {
|
||||
val plan =
|
||||
plan(
|
||||
"SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " +
|
||||
"ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0",
|
||||
"ORDER BY ${albumNameSortKey(SortNameMode.SortName)}, id LIMIT 20 OFFSET 0",
|
||||
)
|
||||
|
||||
assertTrue(plan, plan.contains("album_search"))
|
||||
@@ -1200,6 +1419,70 @@ class LibraryDaoTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNamePreferenceChoosesTheOrderKey() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
|
||||
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
|
||||
artist(sourceId, "ar-3", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
|
||||
artist(sourceId, "ar-4", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Zzz", year = null, starred = null)
|
||||
.copy(sortName = "Aaa", albumArtistSort = "Zzz", albumArtist = "Beatles"),
|
||||
album(sourceId, "al-2", "Aaa", year = null, starred = null)
|
||||
.copy(sortName = "Zzz", albumArtistSort = "Aaa", albumArtist = "Aaa"),
|
||||
album(sourceId, "al-3", "Beatles", year = null, starred = null)
|
||||
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Zebra"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
|
||||
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Beatles", "Zzz"),
|
||||
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz", "Beatles"),
|
||||
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Beatles", "Zzz"),
|
||||
dao
|
||||
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
|
||||
runTest {
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
@@ -153,6 +154,111 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
migrated.execSQL(
|
||||
"UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " +
|
||||
"downloadedCoverArts = 'c' WHERE id = 'ar1'",
|
||||
)
|
||||
assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistArtMigrationBackfillsExistingLibraries() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 3, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " +
|
||||
"year, starred, songCount) VALUES " +
|
||||
"(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " +
|
||||
"(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " +
|
||||
"(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " +
|
||||
"(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " +
|
||||
"(1, 's3', 'al3', 'ar1', 'Three')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " +
|
||||
"(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)",
|
||||
)
|
||||
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistSearchTriggersIgnoreArtOnlyUpdates() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertTrue(artistSearchTriggersOptimized(migrated))
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFreshDatabaseCallbackOptimizesTheArtistSearchTriggers() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { connection ->
|
||||
applySchema(connection, 36)
|
||||
connection.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
ArtistSearchTriggerOptimization.onOpen(connection)
|
||||
assertTrue(artistSearchTriggersOptimized(connection))
|
||||
val before = long(connection, "SELECT total_changes()")
|
||||
connection.execSQL("UPDATE artists SET coverArts = 'a' WHERE id = 'ar1'")
|
||||
|
||||
assertEquals(before + 1, long(connection, "SELECT total_changes()"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||
runTest {
|
||||
@@ -395,6 +501,16 @@ class MigrationsTest {
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
private fun triggerSql(
|
||||
connection: SQLiteConnection,
|
||||
trigger: String,
|
||||
): String =
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").use { statement ->
|
||||
statement.bindText(1, trigger)
|
||||
check(statement.step()) { "No trigger $trigger" }
|
||||
statement.getText(0)
|
||||
}
|
||||
|
||||
private fun indexColumn(
|
||||
connection: SQLiteConnection,
|
||||
index: String,
|
||||
@@ -428,6 +544,10 @@ class MigrationsTest {
|
||||
for (i in 0 until indices.length()) {
|
||||
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
||||
}
|
||||
val triggers = entity.optJSONArray("contentSyncTriggers") ?: continue
|
||||
for (i in 0 until triggers.length()) {
|
||||
connection.execSQL(triggers.getString(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,12 @@ class QueryTimingMeasurementTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -51,6 +51,22 @@ class UserPreferencesTest {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNameModeRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(SortNameMode.Name, prefs.sortNameMode().first())
|
||||
|
||||
prefs.setSortNameMode(SortNameMode.NonLatin)
|
||||
|
||||
assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSelectedLibraryTabRoundTrips() =
|
||||
runTest {
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
|
||||
private val requestedArt = CopyOnWriteArrayList<String>()
|
||||
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
||||
private var failArtworkFetch = false
|
||||
private val failingArtUrls = CopyOnWriteArrayList<String>()
|
||||
private val fetcher =
|
||||
ArtworkFetcher { url ->
|
||||
requestedArt += url
|
||||
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
|
||||
if (failArtworkFetch) throw IOException("boom")
|
||||
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
|
||||
byteArrayOf(1, 2, 3)
|
||||
}
|
||||
|
||||
@@ -344,7 +346,10 @@ class DownloadRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
writeFile(1, "s1", "audio")
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
runBlocking {
|
||||
repository.download(1, "s1")
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART).copy(downloadedCoverArts = listOf(ALBUM_ART))))
|
||||
}
|
||||
val id = engine.requests.single().first
|
||||
|
||||
runBlocking { repository.removeSource(1) }
|
||||
@@ -352,6 +357,13 @@ class DownloadRepositoryTest {
|
||||
assertTrue(engine.cancelled.contains(id))
|
||||
assertFalse(file(1, "s1").exists())
|
||||
assertTrue(allRows().isEmpty())
|
||||
assertTrue(
|
||||
db
|
||||
.libraryDao()
|
||||
.artistOnce(1, "ar1")!!
|
||||
.downloadedCoverArts
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -381,6 +393,95 @@ class DownloadRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
|
||||
}
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
|
||||
val key = artistImageKey(1, "ar1")
|
||||
assertNotNull(artwork.uri(1, key))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
assertNotNull(artwork.uri(1, key))
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertNull(artwork.uri(1, key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(
|
||||
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
|
||||
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
|
||||
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
|
||||
}
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val trackKey = artistImageKey(1, "ar-track")
|
||||
val albumKey = artistImageKey(1, "ar-album")
|
||||
assertNotNull(artwork.uri(1, trackKey))
|
||||
assertNotNull(artwork.uri(1, albumKey))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
assertNotNull(artwork.uri(1, trackKey))
|
||||
assertNotNull(artwork.uri(1, albumKey))
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertNull(artwork.uri(1, trackKey))
|
||||
assertNull(artwork.uri(1, albumKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
|
||||
}
|
||||
failingArtUrls += "https://img.example/ar1.jpg"
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.start()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val id = engine.requests.single().first
|
||||
|
||||
engine.complete(id, total = 100)
|
||||
writeFile(1, "s1", "audio")
|
||||
runBlocking { repository.reconcile() }
|
||||
|
||||
await { row(1, "s1")?.status == DownloadStatus.Completed }
|
||||
assertEquals(
|
||||
listOf(ALBUM_ART),
|
||||
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
|
||||
)
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
|
||||
runTest {
|
||||
@@ -1282,8 +1383,19 @@ class DownloadRepositoryTest {
|
||||
songCount = 2,
|
||||
)
|
||||
|
||||
private fun artist(coverArt: String?) =
|
||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
|
||||
private fun artist(
|
||||
coverArt: String?,
|
||||
artistImageUrl: String? = null,
|
||||
) = Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = coverArt,
|
||||
artistImageUrl = artistImageUrl,
|
||||
songCount = 2,
|
||||
)
|
||||
|
||||
private fun seedLibrary(address: String = "http://localhost:4533/") {
|
||||
runBlocking {
|
||||
|
||||
@@ -76,6 +76,7 @@ class LibraryRepositoryTest {
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
showMessage = { messages += it.resolve(resources) },
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
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
|
||||
@@ -283,6 +285,35 @@ class SourceRepositoryTest {
|
||||
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeSourceBecomesReadyAfterThePreferencesLoad() =
|
||||
runBlocking {
|
||||
repository.addSource("nav", "http://a.example/", "u", "p", false)
|
||||
|
||||
val active = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null } }
|
||||
assertEquals(repository.activeSourceId().first(), active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
|
||||
runBlocking {
|
||||
repository.addSource("nav", "http://a.example/", "u", "p", false)
|
||||
val sourceId = awaitActiveSourceId()
|
||||
val url = "https://img.example/ar1.jpg"
|
||||
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
|
||||
assertEquals(url, online?.url)
|
||||
|
||||
val key = artistImageKey(sourceId, "ar1")
|
||||
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
|
||||
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
|
||||
|
||||
repository.setOfflineMode(true)
|
||||
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
|
||||
|
||||
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
|
||||
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
||||
}
|
||||
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
|
||||
|
||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||
|
||||
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<artists>
|
||||
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
|
||||
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"
|
||||
artistImageUrl="https://img.example/radiohead.jpg"/>
|
||||
<artist id="ar2" name="Portishead" albumCount="3"/>
|
||||
</artists>
|
||||
</subsonic-response>
|
||||
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
|
||||
assertEquals(7L, artists[0].sourceId)
|
||||
assertEquals(9L, artists[0].albumCount)
|
||||
assertEquals(1577934245L, artists[0].starred)
|
||||
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
|
||||
assertEquals(null, artists[1].artistImageUrl)
|
||||
assertEquals(null, artists[1].starred)
|
||||
}
|
||||
|
||||
@@ -742,6 +745,29 @@ class SubsonicXmlTest {
|
||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsPlaylistOwnerAndSharing() {
|
||||
val playlists =
|
||||
parse(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<playlists>
|
||||
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
|
||||
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
|
||||
<playlist id="pl3" name="Numeric" owner="austin" public="1" songCount="1" duration="60"/>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
tag = "playlist",
|
||||
create = { SubsonicXml.playlist(1, it) },
|
||||
)
|
||||
|
||||
assertEquals("austin", playlists[0].owner)
|
||||
assertTrue(playlists[0].shared)
|
||||
assertEquals(false, playlists[1].shared)
|
||||
assertTrue(playlists[2].shared)
|
||||
}
|
||||
|
||||
private class CountingInputStream(
|
||||
input: InputStream,
|
||||
) : FilterInputStream(input) {
|
||||
|
||||
@@ -258,6 +258,19 @@ class SyncServiceTest {
|
||||
assertEquals(5678L, stored?.starred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresPlaylistOwnerAndSharing() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source = FakeMusicSource(playlists = listOf(playlist("p1").copy(owner = "austin", shared = true)))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val stored = db.libraryDao().playlist(1, "p1").first()
|
||||
assertEquals("austin", stored?.owner)
|
||||
assertEquals(true, stored?.shared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresSongGenresInOrder() =
|
||||
runTest {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.playback
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -70,6 +71,21 @@ class MediaItemTest {
|
||||
assertEquals(gain, item.replayGain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesEveryArtist() {
|
||||
val artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B"))
|
||||
|
||||
val item =
|
||||
queueMediaItem(
|
||||
song.copy(artists = artists),
|
||||
local = null,
|
||||
stream = "http://server/stream?s=s1",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
assertEquals(artists, item.artistRefs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesItsGainContext() {
|
||||
val item =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -57,6 +58,29 @@ class QueueItemTest {
|
||||
assertEquals(true, item.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carriesEveryArtist() {
|
||||
val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "A • B",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
)
|
||||
|
||||
val item = AlbumSongItem(song, coverArt = null).toQueueItem()
|
||||
|
||||
assertEquals(listOf("A", "B"), item.artists.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithoutGainCarriesNoGain() {
|
||||
val item =
|
||||
|
||||
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.playback.RepeatMode
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
|
||||
@@ -321,6 +322,38 @@ class AppScreenshotTest {
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_bio_expanded.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistDetailArtGrid() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistDetailScreen(
|
||||
artist = Fixtures.artists.first(),
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
|
||||
),
|
||||
art = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||
artThumbnail = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
|
||||
heroArt =
|
||||
ArtistArt(
|
||||
covers =
|
||||
Fixtures.albums
|
||||
.take(3)
|
||||
.mapNotNull { it.coverArt }
|
||||
.map { CoverArtRef(it, "test:$it") },
|
||||
),
|
||||
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
onBack = {},
|
||||
onAlbumClick = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
awaitText("Kid A")
|
||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_art_grid.png")
|
||||
}
|
||||
|
||||
private fun renderArtistDetail(bioExpanded: Boolean) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
@@ -584,6 +617,16 @@ class AppScreenshotTest {
|
||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||
artistArt = { artist ->
|
||||
ArtistArt(
|
||||
covers =
|
||||
Fixtures.albums
|
||||
.filter { it.artistId == artist.id }
|
||||
.take(4)
|
||||
.mapNotNull { it.coverArt }
|
||||
.map { CoverArtRef(it, "test:$it") },
|
||||
)
|
||||
},
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
|
||||
@@ -40,12 +40,14 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.data.repo.SEARCH_RESULT_LIMIT
|
||||
import com.subtracks.data.repo.normalizeBiography
|
||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||
import com.subtracks.data.source.subsonic.TestServer
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import com.subtracks.data.sync.SyncService
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.home.HomeFeed
|
||||
import com.subtracks.ui.home.HomeScreen
|
||||
import com.subtracks.ui.library.AlbumDetailScreen
|
||||
@@ -82,11 +84,15 @@ import javax.imageio.ImageIO
|
||||
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
|
||||
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
|
||||
* so reruns do not re-fetch it.
|
||||
*
|
||||
* The device is a 393x786 dp phone at 440 dpi, which renders 1080x2161. A capture one pixel over
|
||||
* 1080x2160 breaks the stores' 2:1 limit, so the captures are cropped to 1080x2160 afterwards, with
|
||||
* the last row taken from the row above it.
|
||||
*/
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
@Config(qualifiers = "w393dp-h786dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||
class DemoScreenshotTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
@@ -123,6 +129,7 @@ class DemoScreenshotTest {
|
||||
HomeScreen(
|
||||
feed = demo.feed,
|
||||
coverArt = demo.coverArt,
|
||||
artistArt = demo::artistArt,
|
||||
playingSongId = demo.homePlayingSong.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -178,6 +185,7 @@ class DemoScreenshotTest {
|
||||
ArtistDetailScreen(
|
||||
artist = demo.artist,
|
||||
artistName = demo.artist.name,
|
||||
bio = demo.artistBio,
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits =
|
||||
@@ -216,6 +224,7 @@ class DemoScreenshotTest {
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
||||
artistArt = demo::artistArt,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
||||
@@ -228,6 +237,7 @@ class DemoScreenshotTest {
|
||||
onQueryChange = {},
|
||||
results = demo.search,
|
||||
coverArt = demo.coverArt,
|
||||
artistArt = demo::artistArt,
|
||||
playingSongId = demo.nowPlayingSong.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -288,7 +298,7 @@ class DemoScreenshotTest {
|
||||
|
||||
private fun reencodeOpaqueRgb(file: File) {
|
||||
val decoded = ImageIO.read(file)
|
||||
val rgb = BufferedImage(decoded.width, decoded.height, BufferedImage.TYPE_INT_RGB)
|
||||
val rgb = BufferedImage(decoded.width, decoded.width * 2, BufferedImage.TYPE_INT_RGB)
|
||||
rgb.createGraphics().run {
|
||||
drawImage(decoded, 0, 0, null)
|
||||
dispose()
|
||||
@@ -329,6 +339,7 @@ class DemoScreenshotTest {
|
||||
val homePlayingAlbum: Album,
|
||||
val homePlayingSong: Song,
|
||||
val artist: Artist,
|
||||
val artistBio: String?,
|
||||
val artistAlbums: List<Album>,
|
||||
val search: SearchResults,
|
||||
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
@@ -371,6 +382,15 @@ class DemoScreenshotTest {
|
||||
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
|
||||
}
|
||||
|
||||
fun artistArt(artist: Artist): ArtistArt {
|
||||
val thumbnail = artist.coverArt?.let { coverArt(it, true) }
|
||||
return ArtistArt(
|
||||
image = thumbnail,
|
||||
thumbnail = thumbnail,
|
||||
covers = artist.coverArts.take(4).mapNotNull { coverArt(it, true) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
||||
private const val DEMO_BASE = "https://demo.navidrome.org/"
|
||||
@@ -417,9 +437,11 @@ class DemoScreenshotTest {
|
||||
val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
|
||||
val homePlayingSong = songsOf(homePlayingAlbum).first()
|
||||
val artist =
|
||||
allArtists.firstOrNull { it.name == "Ugress" }
|
||||
allArtists.firstOrNull { it.name == "Nine Inch Nails" }
|
||||
?: allArtists.firstOrNull { it.name == "pornophonique" }
|
||||
?: allArtists.maxByOrNull { it.albumCount }
|
||||
?: error("demo library has no artists")
|
||||
val artistBio = normalizeBiography(source.artistBiography(artist.id))
|
||||
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
||||
|
||||
val coverArtIds =
|
||||
@@ -509,6 +531,7 @@ class DemoScreenshotTest {
|
||||
homePlayingAlbum = homePlayingAlbum,
|
||||
homePlayingSong = homePlayingSong,
|
||||
artist = artist,
|
||||
artistBio = artistBio,
|
||||
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
||||
search = search,
|
||||
coverArt = coverArt,
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.subtracks.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
@@ -16,6 +18,9 @@ import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeDown
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStates
|
||||
import androidx.paging.PagingData
|
||||
@@ -342,6 +347,204 @@ class LibraryScreenTest {
|
||||
assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun aLongTabKeepsTheGearFullSizeAndScrollsIntoView() {
|
||||
var labelWidth = 0f
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
labelWidth =
|
||||
rememberTextMeasurer()
|
||||
.measure(
|
||||
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
).size.width
|
||||
.toFloat()
|
||||
LibraryTabs(
|
||||
pagerState =
|
||||
remember {
|
||||
PagerState(
|
||||
currentPage = LibraryTab.Playlists.ordinal,
|
||||
pageCount = { LibraryTab.entries.size },
|
||||
)
|
||||
},
|
||||
onTabSelected = {},
|
||||
onOpenSettings = {},
|
||||
artwork = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||
.assertWidthIsEqualTo(40.dp)
|
||||
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "ar-rEG-w320dp-h640dp-xhdpi")
|
||||
fun aLongTabScrollsIntoViewInRtl() {
|
||||
var labelWidth = 0f
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
labelWidth =
|
||||
rememberTextMeasurer()
|
||||
.measure(
|
||||
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
).size.width
|
||||
.toFloat()
|
||||
LibraryTabs(
|
||||
pagerState =
|
||||
remember {
|
||||
PagerState(
|
||||
currentPage = LibraryTab.Playlists.ordinal,
|
||||
pageCount = { LibraryTab.entries.size },
|
||||
)
|
||||
},
|
||||
onTabSelected = {},
|
||||
onOpenSettings = {},
|
||||
artwork = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||
.assertWidthIsEqualTo(40.dp)
|
||||
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun movingToALongTabScrollsItIntoView() {
|
||||
val selectedTab = mutableStateOf(LibraryTab.Home)
|
||||
var labelWidth = 0f
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
labelWidth =
|
||||
rememberTextMeasurer()
|
||||
.measure(
|
||||
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
).size.width
|
||||
.toFloat()
|
||||
LibraryScreen(
|
||||
selectedTab = selectedTab.value,
|
||||
onTabSelected = { selectedTab.value = it },
|
||||
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.runOnIdle { selectedTab.value = LibraryTab.Playlists }
|
||||
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
|
||||
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Playlists).width >= expected - 1f }
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||
.assertWidthIsEqualTo(40.dp)
|
||||
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun returningToTheFirstTabLeavesAGapAtTheEdge() {
|
||||
val selectedTab = mutableStateOf(LibraryTab.Playlists)
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = selectedTab.value,
|
||||
onTabSelected = { selectedTab.value = it },
|
||||
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.runOnIdle { selectedTab.value = LibraryTab.Home }
|
||||
val expectedGap = with(composeRule.density) { TAB_EDGE_GAP.toPx() }
|
||||
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Home).left <= expectedGap + 1f }
|
||||
|
||||
assertEquals(
|
||||
"the first tab should keep a gap from the screen edge",
|
||||
expectedGap,
|
||||
tabBounds(LibraryTab.Home).left,
|
||||
1f,
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertTabFullyVisible(
|
||||
tab: LibraryTab,
|
||||
labelWidth: Float,
|
||||
) {
|
||||
val bounds = tabBounds(tab)
|
||||
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
|
||||
assertEquals("the selected tab should be shown in full, but was $bounds", expected, bounds.width, 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||
fun theOfflineIndicatorGoesOnline() {
|
||||
var exited = false
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
LibraryScreen(
|
||||
selectedTab = LibraryTab.Playlists,
|
||||
onTabSelected = {},
|
||||
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||
coverArt = { _, _ -> null },
|
||||
onAlbumClick = {},
|
||||
onArtistClick = {},
|
||||
onPlaylistClick = {},
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
offline = true,
|
||||
onExitOffline = { exited = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(R.string.offline_mode_status))
|
||||
.performClick()
|
||||
assertTrue("the offline indicator should leave offline mode", exited)
|
||||
}
|
||||
|
||||
private fun tabBounds(tab: LibraryTab) =
|
||||
composeRule
|
||||
.onNodeWithContentDescription(composeRule.activity.getString(tab.label))
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot
|
||||
|
||||
private val settledLoadStates =
|
||||
LoadStates(
|
||||
refresh = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
@@ -378,3 +581,6 @@ class LibraryScreenTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TAB_LABEL_CHROME = 56.dp
|
||||
private val TAB_EDGE_GAP = 8.dp
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ArtistArtTest {
|
||||
@Test
|
||||
fun providerImageWinsOverArtistAndAlbumArt() {
|
||||
val calls = mutableListOf<String>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(artistImageUrl = "https://img/artist"),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
|
||||
artistImage = { id, url ->
|
||||
calls += "image:$id:$url"
|
||||
CoverArtRef("https://img/artist", "cache:image:$id")
|
||||
},
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(CoverArtRef("https://img/artist", "cache:image:ar1"), art.image)
|
||||
assertTrue(art.covers.isEmpty())
|
||||
assertEquals(listOf("image:ar1:https://img/artist"), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistCoverArtWinsOverMaterializedAlbumArt() {
|
||||
val thumbnails = mutableListOf<Boolean>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
|
||||
coverArt = { id, thumbnail ->
|
||||
thumbnails += thumbnail
|
||||
id?.let { CoverArtRef(it, "cache:$it:$thumbnail") }
|
||||
},
|
||||
artistImage = { _, _ -> null },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(CoverArtRef("own", "cache:own:true"), art.image)
|
||||
assertTrue(art.covers.isEmpty())
|
||||
assertTrue(thumbnails.all { it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun iconUsesThumbnailsAndLimitsTheGridToFourCovers() {
|
||||
val thumbnails = mutableListOf<Boolean>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArts = listOf("a", "b", "c", "d", "e")),
|
||||
coverArt = { id, thumbnail ->
|
||||
thumbnails += thumbnail
|
||||
id?.let { CoverArtRef(it, "cache:$it") }
|
||||
},
|
||||
artistImage = { _, _ -> null },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("a", "b", "c", "d"), art.covers.map { it.url })
|
||||
assertEquals(listOf(true, true, true, true), thumbnails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleHeroCoverLoadsFullSizeWhileMultipleCoversUseThumbnails() {
|
||||
val thumbnails = mutableListOf<Boolean>()
|
||||
val coverArt = { id: String?, thumbnail: Boolean ->
|
||||
thumbnails += thumbnail
|
||||
id?.let { CoverArtRef(it, "cache:$it") }
|
||||
}
|
||||
val artistImage: (String, String?) -> CoverArtRef? = { _, _ -> null }
|
||||
|
||||
val single =
|
||||
resolveArtistArt(
|
||||
artist(coverArts = listOf("a")),
|
||||
coverArt,
|
||||
artistImage,
|
||||
offline = false,
|
||||
forIcon = false,
|
||||
)
|
||||
assertEquals(listOf(true, false), thumbnails)
|
||||
assertEquals("a", single.thumbnail?.url)
|
||||
|
||||
thumbnails.clear()
|
||||
resolveArtistArt(artist(coverArts = listOf("a", "b")), coverArt, artistImage, offline = false, forIcon = false)
|
||||
assertEquals(listOf(true, true), thumbnails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineUsesDownloadedCoversAndNeverOnlineOnlyOnes() {
|
||||
val requested = mutableListOf<String>()
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist =
|
||||
artist(
|
||||
coverArt = "online-own",
|
||||
coverArts = listOf("online-a", "online-b"),
|
||||
downloadedCoverArts = listOf("downloaded-a", "downloaded-b"),
|
||||
),
|
||||
coverArt = { id, _ ->
|
||||
requested += id ?: "null"
|
||||
id?.takeIf { it.startsWith("downloaded") }?.let { CoverArtRef(it, "cache:$it") }
|
||||
},
|
||||
artistImage = { _, _ -> null },
|
||||
offline = true,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("downloaded-a", "downloaded-b"), art.covers.map { it.url })
|
||||
assertEquals(setOf("online-own", "downloaded-a", "downloaded-b"), requested.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theProviderAndArtistImagesCarryTheNextSourceAsAFallback() {
|
||||
val provider =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "own", artistImageUrl = "https://img/artist", coverArts = listOf("a", "b")),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
|
||||
artistImage = { _, _ -> CoverArtRef("https://img/artist", "cache:image") },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(CoverArtRef("own", "cache:own"), provider.fallback?.image)
|
||||
assertEquals(
|
||||
listOf("a", "b"),
|
||||
provider.fallback
|
||||
?.fallback
|
||||
?.covers
|
||||
?.map { it.url },
|
||||
)
|
||||
|
||||
val own =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
|
||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
|
||||
artistImage = { _, _ -> null },
|
||||
offline = false,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("a", "b"), own.fallback?.covers?.map { it.url })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noArtResolvesToAnEmptySelectionInsteadOfAStaleFallback() {
|
||||
val art =
|
||||
resolveArtistArt(
|
||||
artist = artist(coverArt = "online-own", coverArts = listOf("a")),
|
||||
coverArt = { _, _ -> null },
|
||||
artistImage = { _, _ -> null },
|
||||
offline = true,
|
||||
forIcon = true,
|
||||
)
|
||||
|
||||
assertNull(art.image)
|
||||
assertTrue(art.covers.isEmpty())
|
||||
}
|
||||
|
||||
private fun artist(
|
||||
coverArt: String? = null,
|
||||
artistImageUrl: String? = null,
|
||||
coverArts: List<String> = emptyList(),
|
||||
downloadedCoverArts: List<String> = emptyList(),
|
||||
) = Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArt = coverArt,
|
||||
artistImageUrl = artistImageUrl,
|
||||
coverArts = coverArts,
|
||||
downloadedCoverArts = downloadedCoverArts,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasScrollAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class HeroDetailScaffoldRestoreTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun aRestoredScreenWaitsForItsRowsBeforeRestoringTheScrollPosition() {
|
||||
var visible by mutableStateOf(true)
|
||||
var rows by mutableIntStateOf(100)
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val holder = rememberSaveableStateHolder()
|
||||
if (visible) {
|
||||
holder.SaveableStateProvider("detail") {
|
||||
HeroDetailScaffold(
|
||||
artwork = null,
|
||||
title = "Album",
|
||||
onBack = {},
|
||||
header = { _, _ -> Box(Modifier.height(200.dp)) },
|
||||
content = { rowModifier -> rowItems(rows, rowModifier) },
|
||||
contentReady = rows > 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Item 20"))
|
||||
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
|
||||
|
||||
composeRule.runOnIdle { visible = false }
|
||||
composeRule.runOnIdle {
|
||||
rows = 0
|
||||
visible = true
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.runOnIdle { rows = 100 }
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun LazyListScope.rowItems(
|
||||
count: Int,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
repeat(count) { index ->
|
||||
item(key = index) {
|
||||
Box(modifier.height(48.dp)) {
|
||||
Text("Item $index")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,18 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filter
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -44,34 +47,69 @@ class ItemContextMenuArtistTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
|
||||
var opened: String? = null
|
||||
fun pluralArtistsShareOneEntryThatOpensTheChooser() {
|
||||
var opened: List<ArtistRef>? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
var show by remember { mutableStateOf(true) }
|
||||
if (show) {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Song(song),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
actions = ItemActions(viewArtist = {}),
|
||||
onDismiss = { show = false },
|
||||
onChooseArtists = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
||||
val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction())
|
||||
entry.assertIsDisplayed()
|
||||
assertTrue(
|
||||
composeRule
|
||||
.onAllNodesWithText("A")
|
||||
.filter(hasClickAction())
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
composeRule
|
||||
.onAllNodesWithText("B")
|
||||
.filter(hasClickAction())
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty(),
|
||||
)
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("B").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
entry.performClick()
|
||||
|
||||
assertEquals("ar2", opened)
|
||||
assertEquals(listOf("ar1", "ar2"), opened?.map { it.id })
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longMenusScrollToTheLastArtist() {
|
||||
fun aSingleArtistOpensDirectly() {
|
||||
var opened: String? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Song(song.copy(artists = listOf(ArtistRef("ar1", "A")))),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onAllNodesWithText("A").filterToOne(hasClickAction()).performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("ar1", opened)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manyArtistsStillShareOneEntry() {
|
||||
var opened: List<ArtistRef>? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
@@ -79,18 +117,23 @@ class ItemContextMenuArtistTest {
|
||||
MenuTarget.Song(
|
||||
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
||||
),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
actions = ItemActions(viewArtist = {}),
|
||||
onDismiss = {},
|
||||
onChooseArtists = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
|
||||
val entry =
|
||||
composeRule
|
||||
.onAllNodesWithText((1..20).joinToString(" · ") { "Artist $it" })
|
||||
.filterToOne(hasClickAction())
|
||||
entry.assertIsDisplayed()
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("Artist 20").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
entry.performClick()
|
||||
|
||||
assertEquals("ar20", opened)
|
||||
assertEquals(20, opened?.size)
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ItemContextMenuInfoTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun anAlbumMenuOpensItsInfo() {
|
||||
var opened: MenuTarget? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Album(album),
|
||||
actions = ItemActions(),
|
||||
onDismiss = {},
|
||||
onInfo = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Info").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals(album, (opened as? MenuTarget.Album)?.album)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistMenuOpensItsInfo() {
|
||||
var opened: MenuTarget? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target =
|
||||
MenuTarget.Artist(
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
),
|
||||
),
|
||||
actions = ItemActions(),
|
||||
onDismiss = {},
|
||||
onInfo = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Info").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("ar1", (opened as? MenuTarget.Artist)?.artist?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaylistMenuOpensItsInfo() {
|
||||
var opened: MenuTarget? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
target =
|
||||
MenuTarget.Playlist(
|
||||
Playlist(
|
||||
sourceId = 1,
|
||||
id = "pl1",
|
||||
name = "Playlist",
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
created = 0,
|
||||
),
|
||||
),
|
||||
actions = ItemActions(),
|
||||
onDismiss = {},
|
||||
onInfo = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Info").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
|
||||
assertEquals("pl1", (opened as? MenuTarget.Playlist)?.playlist?.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class LibraryInfoDialogTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun anAlbumShowsEveryArtistAndBothSortNames() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
AlbumInfoDialog(
|
||||
album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "A",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = "Rock",
|
||||
year = 1999,
|
||||
starred = null,
|
||||
songCount = 12,
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
sortName = "Album, The",
|
||||
albumArtistSort = "A, The",
|
||||
),
|
||||
download = null,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("A · B").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Album, The").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("A, The").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("1999").assertIsDisplayed()
|
||||
|
||||
val label = composeRule.onNodeWithText("Album artist sort").fetchSemanticsNode().boundsInRoot
|
||||
val value = composeRule.onNodeWithText("A, The").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("the value must be separated from its label", value.left > label.right)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistShowsItsSortNameAndCounts() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistInfoDialog(
|
||||
artist =
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "The Artist",
|
||||
albumCount = 4,
|
||||
starred = null,
|
||||
songCount = 40,
|
||||
sortName = "Artist, The",
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist, The").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("4").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("40").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistHidesASortNameThatMatchesItsName() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
ArtistInfoDialog(
|
||||
artist =
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "The Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
sortName = "the artist",
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Sort name").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlbumHidesSortNamesThatMatchTheRegularNames() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
AlbumInfoDialog(
|
||||
album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 0,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
sortName = "album",
|
||||
albumArtistSort = "artist",
|
||||
),
|
||||
download = null,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Album artist sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlbumsDatesAreListedTogether() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
AlbumInfoDialog(
|
||||
album =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
id = "al1",
|
||||
artistId = "ar1",
|
||||
name = "Album",
|
||||
albumArtist = "Artist",
|
||||
created = 86400,
|
||||
coverArt = null,
|
||||
genre = null,
|
||||
year = null,
|
||||
starred = null,
|
||||
songCount = 1,
|
||||
playCount = 3,
|
||||
played = 172800,
|
||||
),
|
||||
download = null,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val added = composeRule.onNodeWithText("Added").fetchSemanticsNode().boundsInRoot
|
||||
val lastPlayed = composeRule.onNodeWithText("Last played").fetchSemanticsNode().boundsInRoot
|
||||
val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("last played must follow added", lastPlayed.top >= added.bottom)
|
||||
assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom)
|
||||
assertEquals(
|
||||
"no row may sit between added and last played",
|
||||
lastPlayed.top - added.top,
|
||||
plays.top - lastPlayed.top,
|
||||
0.5f,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPlaylistShowsOwnerSharingLengthAndDates() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
PlaylistInfoDialog(
|
||||
playlist =
|
||||
Playlist(
|
||||
sourceId = 1,
|
||||
id = "pl1",
|
||||
name = "Mix",
|
||||
comment = null,
|
||||
coverArt = null,
|
||||
songCount = 12,
|
||||
created = 1600000000,
|
||||
changed = 1700000000,
|
||||
duration = 3725,
|
||||
owner = "austin",
|
||||
shared = true,
|
||||
),
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Owner").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("austin").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Shared").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Yes").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("12").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("1:02:05").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Created").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Updated").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleArtistTrackUsesSingularLabels() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song(artists = listOf(ArtistRef("ar1", "Alpha")), artistSort = "Beta"),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateAndBlankArtistRefsStaySingular() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song =
|
||||
song(
|
||||
artists = listOf(ArtistRef("ar1", "Alpha"), ArtistRef("ar2", "Alpha"), ArtistRef("ar3", "")),
|
||||
artistSort = "Beta",
|
||||
),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMixedArtistTrackUsesPluralLabelsForBothRows() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song =
|
||||
song(
|
||||
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
|
||||
artistSort = "Alpha",
|
||||
),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists sort").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTrackHidesATitleAndArtistSortThatMatchTheRegularNames() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song(artists = listOf(ArtistRef("ar1", "Artist")), sortName = "song", artistSort = "artist"),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMixedArtistTrackWithoutASortShowsOnlyTheArtistsRow() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song =
|
||||
song(
|
||||
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
|
||||
artistSort = "",
|
||||
),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist A · Artist B").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTrackWithOnlyAnArtistSortShowsOnlyTheSortRow() {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
SongInfoDialog(
|
||||
song = song(artists = emptyList(), artist = null, artistSort = "Alpha"),
|
||||
download = null,
|
||||
localFile = null,
|
||||
streamEncoding = null,
|
||||
onDismiss = {},
|
||||
genres = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private fun song(
|
||||
artists: List<ArtistRef>,
|
||||
artist: String? = artists.firstOrNull()?.name,
|
||||
sortName: String = "Song",
|
||||
artistSort: String = artist.orEmpty(),
|
||||
) = Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = artists.firstOrNull()?.id,
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = artist,
|
||||
duration = 200,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = artists,
|
||||
sortName = sortName,
|
||||
artistSort = artistSort,
|
||||
)
|
||||
}
|
||||
@@ -90,7 +90,13 @@ class DownloadsViewModelTest {
|
||||
)
|
||||
viewModel =
|
||||
DownloadsViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
),
|
||||
downloadRepository,
|
||||
controller,
|
||||
"Unknown artist",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import com.subtracks.data.model.Artist
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ArtistDetailArtTest {
|
||||
@Test
|
||||
fun aCreditedArtistWithoutARowUsesTheCreditCovers() {
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = null,
|
||||
artistId = "ar1",
|
||||
offline = false,
|
||||
sourceId = 7,
|
||||
covers = listOf("a", "b"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(7L, resolved?.sourceId)
|
||||
assertEquals("ar1", resolved?.id)
|
||||
assertEquals(listOf("a", "b"), resolved?.coverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOfflineArtistKeepsItsDownloadedCovers() {
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = artist(coverArts = listOf("online"), downloadedCoverArts = listOf("downloaded")),
|
||||
artistId = "ar1",
|
||||
offline = true,
|
||||
sourceId = 7,
|
||||
covers = listOf("online", "other"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("online", "other"), resolved?.coverArts)
|
||||
assertEquals(listOf("downloaded"), resolved?.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOfflineArtistWithoutDownloadedCoversFallsBackToTheCreditCovers() {
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = artist(downloadedCoverArts = emptyList()),
|
||||
artistId = "ar1",
|
||||
offline = true,
|
||||
sourceId = 7,
|
||||
covers = listOf("a", "b"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("a", "b"), resolved?.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistWithMaterializedCoversIsKeptWhenTheCreditQueryIsEmpty() {
|
||||
val original = artist(coverArts = listOf("materialized"))
|
||||
|
||||
val resolved =
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = original,
|
||||
artistId = "ar1",
|
||||
offline = false,
|
||||
sourceId = 7,
|
||||
covers = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(original, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noArtistAndNoCoversClearsTheArt() {
|
||||
assertNull(
|
||||
resolveArtistArtInput(
|
||||
ArtistArtInput(
|
||||
artist = null,
|
||||
artistId = "ar1",
|
||||
offline = false,
|
||||
sourceId = 7,
|
||||
covers = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun artist(
|
||||
coverArts: List<String> = emptyList(),
|
||||
downloadedCoverArts: List<String> = emptyList(),
|
||||
) = Artist(
|
||||
sourceId = 7,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArts = coverArts,
|
||||
downloadedCoverArts = downloadedCoverArts,
|
||||
)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ class NowPlayingRouteTest {
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
).also { it.start() }
|
||||
library = LibraryRepository(db, sources, serverActions, scope = repoScope)
|
||||
library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
|
||||
handle = FakePlayerHandle()
|
||||
controller =
|
||||
PlaybackController(
|
||||
|
||||
@@ -90,7 +90,13 @@ class SearchViewModelTest {
|
||||
)
|
||||
viewModel =
|
||||
SearchViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
),
|
||||
sourceRepository,
|
||||
controller,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -31,7 +32,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
@@ -42,7 +43,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOff() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = true) { requested = it }
|
||||
renderSettings(keepScreenOn = true, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
@@ -53,7 +54,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule
|
||||
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
|
||||
@@ -63,9 +64,29 @@ class SettingsScreenTest {
|
||||
assertEquals(true, requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNamesRowChangesTheSortNameMode() {
|
||||
var requested: SortNameMode? = null
|
||||
renderSettings(
|
||||
keepScreenOn = false,
|
||||
onKeepScreenOnChange = {},
|
||||
onSortNameModeChange = { requested = it },
|
||||
scrollTo = "Sort names",
|
||||
)
|
||||
|
||||
composeRule.onNodeWithText("Sort names").performClick()
|
||||
composeRule.onNodeWithText("Use sort names").performClick()
|
||||
composeRule.onNodeWithText("Done").performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(SortNameMode.SortName, requested)
|
||||
}
|
||||
|
||||
private fun renderSettings(
|
||||
keepScreenOn: Boolean,
|
||||
onKeepScreenOnChange: (Boolean) -> Unit,
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
scrollTo: String = "Keep screen on",
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
@@ -88,6 +109,7 @@ class SettingsScreenTest {
|
||||
onDownloadOverMeteredChange = {},
|
||||
onScrobblingChange = {},
|
||||
onKeepScreenOnChange = onKeepScreenOnChange,
|
||||
onSortNameModeChange = onSortNameModeChange,
|
||||
onAddServer = {},
|
||||
onOpenDownloads = {},
|
||||
onOpenLicenses = {},
|
||||
@@ -97,7 +119,7 @@ class SettingsScreenTest {
|
||||
}
|
||||
}
|
||||
awaitText("Add server")
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(scrollTo))
|
||||
}
|
||||
|
||||
private fun awaitText(text: String) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
## What's new
|
||||
|
||||
- ReplayGain: normalizes loudness from the tags your server reports. Auto, the default, uses album gain for album queues and track gain for playlists, singles and shuffles. Playback settings also offer track gain, album gain, off and a preamp
|
||||
- Artist pages show an expandable biography, artwork from the server or built from the artist's albums, and separate sections for album credits and appearances
|
||||
- Sort lists can use regular names (the default), sort names, or sort names only for names without Latin letters
|
||||
- Improved handling for albums and tracks with multiple artists, including their names in song subtitles, the queue and info dialogs
|
||||
- Info dialogs in album, artist and playlist menus, listing credited artists, sort names, owner and sharing, and play and download details
|
||||
- Keep the screen on while now playing is open setting, off by default (thanks @chiangww!)
|
||||
|
||||
## Fixed
|
||||
|
||||
- Album and artist scroll positions survive navigating away and back
|
||||
- Search keeps its query and focus when you return
|
||||
- The library tab strip scrolls, so the settings gear is never squeezed out
|
||||
- Notification progress for streams of unknown length, such as Opus/Ogg transcodes
|
||||
- Album grid no longer shifts covers after a scrobble reloads the list
|
||||
- Now playing screen: artwork wraps with repeat, and cover shadows are no longer clipped
|
||||
|
||||
## Performance
|
||||
|
||||
- Faster library sync on servers without `search3`
|
||||
- Artist lists and downloads stay fast on large libraries
|
||||
|
||||
**Full changelog:** https://github.com/austinried/subtracks/compare/v3.0.0...v3.1.0
|
||||
@@ -0,0 +1,12 @@
|
||||
- ReplayGain: loudness from server tags, with Auto, track, album, off, preamp
|
||||
- Artist pages: bio, art, credits and appearances
|
||||
- Sort lists: regular names or sort names
|
||||
- Improved handling for multi-artist albums/tracks
|
||||
- Keep the screen on while now playing is open setting, off by default (thanks @chiangww!)
|
||||
|
||||
Fixed
|
||||
|
||||
- Notification progress for unknown-length streams (Opus/Ogg)
|
||||
- Now playing artwork and shadow fixes
|
||||
|
||||
Release notes: https://github.com/austinried/subtracks/releases/tag/v3.1.0
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 513 KiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 371 KiB |