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"
|
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
- name: Check release notes exist
|
- 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
|
- name: Write Play service account
|
||||||
if: steps.stable.outputs.value == 'true'
|
if: steps.stable.outputs.value == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -123,7 +125,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
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
|
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||||
args+=(--prerelease)
|
args+=(--prerelease)
|
||||||
fi
|
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.
|
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||||
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
- **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.
|
- **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.
|
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ android {
|
|||||||
applicationId = "com.subtracks"
|
applicationId = "com.subtracks"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 14
|
||||||
versionName = "3.0.0"
|
versionName = "3.1.0"
|
||||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
|||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(driver)
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
|
.addCallback(ArtistSearchTriggerOptimization)
|
||||||
.addCallback(SqliteLifecycleLogging)
|
.addCallback(SqliteLifecycleLogging)
|
||||||
.build()
|
.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() {
|
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
Log.i("sql", "created fresh database")
|
Log.i("sql", "created fresh database")
|
||||||
|
|||||||
@@ -162,12 +162,15 @@ interface DownloadDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
"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, " +
|
"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 " +
|
"FROM song_downloads d " +
|
||||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"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 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>
|
suspend fun artwork(): List<DownloadArtwork>
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ internal const val ALBUMS_FILTER =
|
|||||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||||
"OR instr(lower(albumArtistSort), 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 = "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_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
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 = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val 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 =
|
internal const val ARTIST_CREDITED =
|
||||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
"(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
|
// 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.
|
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||||
"SELECT albums.id FROM albums " +
|
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"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') " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"UNION " +
|
"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 " +
|
"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 " +
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"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') " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"UNION " +
|
"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 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 " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||||
"AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
"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 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 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 " +
|
"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 " +
|
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||||
"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 =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"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 (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||||
"OR instr(lower(sortName), 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 =
|
internal const val ARTIST_COLUMNS =
|
||||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||||
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
|
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||||
"artists.sortName, artists.credited"
|
"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
|
// 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.
|
// an artist matches the albums its detail screen lists.
|
||||||
@@ -206,11 +325,9 @@ internal const val ARTISTS_SELECT =
|
|||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||||
") ELSE artists.albumCount END AS albumCount, " +
|
") ELSE artists.albumCount END AS albumCount, " +
|
||||||
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||||
"artists.sortName, artists.credited "
|
"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 = "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_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_STARRED = "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 = "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"
|
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 =
|
internal const val PLAYLISTS_FILTER =
|
||||||
"FROM playlists WHERE sourceId = :sourceId " +
|
"FROM playlists WHERE sourceId = :sourceId " +
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
"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' " +
|
"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 " +
|
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||||
") ELSE playlists.songCount END AS songCount, " +
|
") 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 = "name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
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)
|
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
|
@Transaction
|
||||||
suspend fun recomputeAggregates(sourceId: Long) {
|
suspend fun recomputeAggregates(sourceId: Long) {
|
||||||
recomputeAlbumPlayData(sourceId)
|
recomputeAlbumPlayData(sourceId)
|
||||||
recomputeArtistAggregates(sourceId)
|
recomputeArtistAggregates(sourceId)
|
||||||
|
recomputeArtistArt(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -381,6 +526,12 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): 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
|
// 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.
|
// sync then overwrites with the server's own number.
|
||||||
@Transaction
|
@Transaction
|
||||||
@@ -471,22 +622,24 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
"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(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Artist>>
|
): Flow<List<Artist>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -503,12 +656,13 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
"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(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<AlbumSongItem>>
|
): Flow<List<AlbumSongItem>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -650,36 +804,40 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
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(
|
fun albumsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): 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(
|
fun albumsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): 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(
|
fun albumsByArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): 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(
|
fun albumsByArtistReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||||
@@ -762,20 +920,22 @@ interface LibraryDao {
|
|||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
): PagingSource<Int, Album>
|
): 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(
|
fun artistsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Artist>
|
): 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(
|
fun artistsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
@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.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
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
|
// 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).
|
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||||
@@ -42,8 +43,9 @@ internal fun albumsDefaultQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery {
|
||||||
val order = albumOrder(sort, descending)
|
val order = albumOrder(sort, descending, sortNameMode)
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||||
}
|
}
|
||||||
@@ -88,8 +90,9 @@ internal fun artistsDefaultQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery {
|
||||||
val order = artistOrder(sort, descending)
|
val order = artistOrder(sort, descending, sortNameMode)
|
||||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||||
stmt.bindLong(1, sourceId)
|
stmt.bindLong(1, sourceId)
|
||||||
stmt.bindInt(2, starredFilter)
|
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(
|
private fun albumOrder(
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
AlbumSort.Name -> {
|
||||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
val name = albumNameSortKey(mode)
|
||||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
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.Artist -> {
|
||||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
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(
|
private fun artistOrder(
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
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 -> {
|
ArtistSort.AlbumCount -> {
|
||||||
|
|||||||
@@ -695,6 +695,80 @@ private suspend fun createSearchIndex(
|
|||||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
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> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -731,4 +805,7 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_32_33,
|
MIGRATION_32_33,
|
||||||
MIGRATION_33_34,
|
MIGRATION_33_34,
|
||||||
MIGRATION_34_35,
|
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.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.model.StringListConverter
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 35,
|
version = 38,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
ArtistRefsConverter::class,
|
ArtistRefsConverter::class,
|
||||||
|
StringListConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
|||||||
coverArt: String,
|
coverArt: String,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): String = "$sourceId:$coverArt:$thumbnail"
|
): 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,
|
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
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 DownloadList { Album, Playlist, Artist }
|
||||||
|
|
||||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||||
@@ -99,17 +104,19 @@ data class DownloadedSong(
|
|||||||
val songSort: String = title,
|
val songSort: String = title,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
val albumId: String?,
|
val albumId: String?,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
|
val albumArtistId: String?,
|
||||||
val albumRow: String?,
|
val albumRow: String?,
|
||||||
val artistRow: String?,
|
val artistRow: String?,
|
||||||
val albumCoverArt: String?,
|
val albumCoverArt: String?,
|
||||||
val artistCoverArt: String?,
|
val artistCoverArt: String?,
|
||||||
|
val albumArtistCoverArt: String?,
|
||||||
) {
|
) {
|
||||||
val hasMissingLibraryRow: Boolean
|
val hasMissingLibraryRow: Boolean
|
||||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ data class ArtistRef(
|
|||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||||
|
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_albumCount",
|
name = "index_artists_albumCount",
|
||||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||||
@@ -88,6 +89,9 @@ data class Artist(
|
|||||||
val albumCount: Long,
|
val albumCount: Long,
|
||||||
val starred: Long?,
|
val starred: Long?,
|
||||||
val coverArt: String? = null,
|
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,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||||
@@ -128,7 +132,9 @@ data class ArtistBio(
|
|||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index("sourceId", "artistId"),
|
Index("sourceId", "artistId"),
|
||||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
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", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_year",
|
name = "index_albums_year",
|
||||||
value = ["sourceId", "year", "sortName", "id"],
|
value = ["sourceId", "year", "sortName", "id"],
|
||||||
@@ -248,6 +254,8 @@ data class Playlist(
|
|||||||
val created: Long,
|
val created: Long,
|
||||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||||
|
val owner: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -592,6 +600,16 @@ class ArtistRefsConverter {
|
|||||||
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
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 {
|
object ArtistRefsCodec {
|
||||||
fun encode(refs: List<ArtistRef>): String =
|
fun encode(refs: List<ArtistRef>): String =
|
||||||
buildString {
|
buildString {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
enum class SortNameMode { SortName, Name, NonLatin }
|
||||||
|
|
||||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||||
|
|
||||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||||
@@ -182,6 +184,17 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
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 }
|
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||||
|
|
||||||
suspend fun setOfflineMode(enabled: Boolean) {
|
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.EngineRequest
|
||||||
import com.subtracks.data.download.EngineStatus
|
import com.subtracks.data.download.EngineStatus
|
||||||
import com.subtracks.data.download.looksLikeAudio
|
import com.subtracks.data.download.looksLikeAudio
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtistBio
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadError
|
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.DownloadedSong
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -66,6 +68,7 @@ class DownloadRepository(
|
|||||||
|
|
||||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||||
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||||
|
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (started) return
|
if (started) return
|
||||||
@@ -350,6 +353,9 @@ class DownloadRepository(
|
|||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
group.forEach { file(sourceId, it.songId).delete() }
|
group.forEach { file(sourceId, it.songId).delete() }
|
||||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
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())
|
sweep(db.downloadDao().all())
|
||||||
}
|
}
|
||||||
@@ -372,6 +378,7 @@ class DownloadRepository(
|
|||||||
.mapNotNull { it.engineId }
|
.mapNotNull { it.engineId }
|
||||||
.let(engine::cancel)
|
.let(engine::cancel)
|
||||||
db.downloadDao().deleteSource(sourceId)
|
db.downloadDao().deleteSource(sourceId)
|
||||||
|
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||||
dir(sourceId).deleteRecursively()
|
dir(sourceId).deleteRecursively()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,13 +420,14 @@ class DownloadRepository(
|
|||||||
) {
|
) {
|
||||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||||
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
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(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||||
|
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
|
||||||
val coverArt =
|
val coverArt =
|
||||||
(
|
(
|
||||||
listOfNotNull(
|
listOfNotNull(album?.coverArt) +
|
||||||
album?.coverArt,
|
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
|
||||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
|
||||||
).distinct()
|
).distinct()
|
||||||
coverArt.forEach { cover ->
|
coverArt.forEach { cover ->
|
||||||
storeArtwork(sourceId, cover, thumbnail = false)
|
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(
|
private suspend fun fetchArtistBio(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
@@ -491,11 +530,15 @@ class DownloadRepository(
|
|||||||
.mapValues { (sourceId, rows) ->
|
.mapValues { (sourceId, rows) ->
|
||||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||||
val covers =
|
val covers =
|
||||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
rows.flatMap { row ->
|
||||||
playlistCovers[sourceId].orEmpty()
|
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||||
covers
|
} + playlistCovers[sourceId].orEmpty()
|
||||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||||
.toSet()
|
(
|
||||||
|
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)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().delete(sourceId, songId)
|
db.downloadDao().delete(sourceId, songId)
|
||||||
|
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +669,11 @@ class DownloadRepository(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
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) {
|
if (updated.status != row.status) {
|
||||||
when (updated.status) {
|
when (updated.status) {
|
||||||
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||||
@@ -642,9 +691,22 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
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")
|
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() {
|
private fun ensurePolling() {
|
||||||
if (pollJob?.isActive == true) return
|
if (pollJob?.isActive == true) return
|
||||||
pollJob =
|
pollJob =
|
||||||
|
|||||||
@@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
|||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -111,12 +116,18 @@ class LibraryRepository(
|
|||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
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(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
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(
|
fun searchPlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -126,7 +137,10 @@ class LibraryRepository(
|
|||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
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) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
@@ -178,26 +192,27 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Album>> =
|
): Flow<PagingData<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(40, enablePlaceholders = true) {
|
pager(40, enablePlaceholders = true) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
if (!downloaded) {
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||||
} else {
|
} else {
|
||||||
val downloadedFilter = 1
|
val downloadedFilter = 1
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> {
|
AlbumSort.Name -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AlbumSort.Artist -> {
|
AlbumSort.Artist -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +258,7 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun artists(
|
fun artists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -252,18 +268,19 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Artist>> =
|
): Flow<PagingData<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(60) {
|
pager(60) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
if (!downloaded) {
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||||
} else {
|
} else {
|
||||||
val downloadedFilter = 1
|
val downloadedFilter = 1
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +318,7 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun playlists(
|
fun playlists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -424,6 +442,11 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||||
|
|
||||||
fun albumDiscs(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicConfig
|
import com.subtracks.data.model.SubsonicConfig
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
@@ -69,6 +70,9 @@ class SourceRepository(
|
|||||||
private val _offline = MutableStateFlow(false)
|
private val _offline = MutableStateFlow(false)
|
||||||
val offline: StateFlow<Boolean> = _offline
|
val offline: StateFlow<Boolean> = _offline
|
||||||
|
|
||||||
|
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSource: StateFlow<Long?> = _activeSource
|
||||||
|
|
||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
@@ -109,6 +113,7 @@ class SourceRepository(
|
|||||||
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||||
}
|
}
|
||||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
|
_activeSource.value = config?.id
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -143,6 +148,18 @@ class SourceRepository(
|
|||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
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(
|
fun networkCoverArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ object SubsonicXml {
|
|||||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||||
starred = attrs.dateAttr("starred"),
|
starred = attrs.dateAttr("starred"),
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
|
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||||
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +129,8 @@ object SubsonicXml {
|
|||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
changed = attrs.dateAttr("changed") ?: 0L,
|
changed = attrs.dateAttr("changed") ?: 0L,
|
||||||
duration = attrs.longAttr("duration") ?: 0L,
|
duration = attrs.longAttr("duration") ?: 0L,
|
||||||
|
owner = attrs.attr("owner").ifEmpty { null },
|
||||||
|
shared = attrs.boolAttr("public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun playlistSong(
|
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.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.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)
|
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 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 =
|
private val albumColumns =
|
||||||
listOf(
|
listOf(
|
||||||
@@ -487,7 +487,8 @@ private val songColumns =
|
|||||||
"artistSort",
|
"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> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
@@ -515,7 +516,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
|
|||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
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() =
|
private fun Album.values() =
|
||||||
listOf<Any?>(
|
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 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)
|
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ fun appModule(
|
|||||||
scope = get(),
|
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<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
single {
|
single {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
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.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
|
|||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
artists = mediaItem.artistRefs(),
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
durationMs =
|
durationMs =
|
||||||
mediaItem.mediaMetadata.extras
|
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_COVER_ART_ID = "coverArtId"
|
||||||
|
private const val EXTRA_ARTISTS = "artists"
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
private const val EXTRA_DURATION_MS = "durationMs"
|
||||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||||
@@ -286,6 +296,7 @@ internal fun queueMediaItem(
|
|||||||
.setExtras(
|
.setExtras(
|
||||||
Bundle().apply {
|
Bundle().apply {
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -36,6 +37,7 @@ data class QueueItem(
|
|||||||
val durationMs: Long? = null,
|
val durationMs: Long? = null,
|
||||||
val replayGain: ReplayGain = ReplayGain(),
|
val replayGain: ReplayGain = ReplayGain(),
|
||||||
val albumContext: Boolean = false,
|
val albumContext: Boolean = false,
|
||||||
|
val artists: List<ArtistRef> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContext(
|
data class QueueContext(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.SongItem
|
import com.subtracks.data.model.SongItem
|
||||||
|
|
||||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||||
@@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
|||||||
id = song.id,
|
id = song.id,
|
||||||
title = song.title,
|
title = song.title,
|
||||||
artist = song.artist,
|
artist = song.artist,
|
||||||
|
artists = song.artistRefs,
|
||||||
album = song.album,
|
album = song.album,
|
||||||
coverArtId = coverArt,
|
coverArtId = coverArt,
|
||||||
durationMs = song.duration?.let { it * 1000 },
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
|
|||||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
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.ContextMenuHost
|
||||||
import com.subtracks.ui.components.ItemContextMenu
|
import com.subtracks.ui.components.ItemContextMenu
|
||||||
import com.subtracks.ui.components.LoadingState
|
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.SongInfoDialog
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.downloads.DownloadsRoute
|
import com.subtracks.ui.downloads.DownloadsRoute
|
||||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
|||||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val contextMenuHost = remember { ContextMenuHost() }
|
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)
|
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
|||||||
nowPlayingLauncher.openNowPlaying.collect {
|
nowPlayingLauncher.openNowPlaying.collect {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
contextMenuHost.dismissTransients()
|
contextMenuHost.dismissTransients()
|
||||||
infoSong = null
|
infoTarget = null
|
||||||
|
artistChooser = null
|
||||||
showingQueue = false
|
showingQueue = false
|
||||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
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))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
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 ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
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))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
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 ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
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))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
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)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
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)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
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)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
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 ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -725,11 +781,30 @@ private fun MainNavigation() {
|
|||||||
target = target,
|
target = target,
|
||||||
actions = contextMenuHost.actions,
|
actions = contextMenuHost.actions,
|
||||||
onDismiss = { contextMenuHost.dismiss() },
|
onDismiss = { contextMenuHost.dismiss() },
|
||||||
onInfo = { infoSong = it },
|
onInfo = { infoTarget = it },
|
||||||
|
onChooseArtists = { artistChooser = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
infoSong?.let { song ->
|
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
|
val genres by
|
||||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
@@ -738,10 +813,37 @@ private fun MainNavigation() {
|
|||||||
download = downloads[song.id],
|
download = downloads[song.id],
|
||||||
localFile = downloadRepository.localFile(song.id),
|
localFile = downloadRepository.localFile(song.id),
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
onDismiss = { infoSong = null },
|
onDismiss = { infoTarget = null },
|
||||||
genres = genres,
|
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,
|
square: Boolean = true,
|
||||||
elevation: Dp = 0.dp,
|
elevation: Dp = 0.dp,
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
|
onError: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
val refKey = ref?.cacheKey
|
val refKey = ref?.cacheKey
|
||||||
val thumbnailKey = thumbnailRef?.cacheKey
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
|
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||||
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
var thumbnailLoaded 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)) }
|
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||||
val letterScheme =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
@@ -115,6 +118,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -131,7 +138,10 @@ fun CoverArt(
|
|||||||
.ratioOrNull()
|
.ratioOrNull()
|
||||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -160,7 +170,10 @@ fun CoverArt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
filterQuality = FilterQuality.High,
|
filterQuality = FilterQuality.High,
|
||||||
)
|
)
|
||||||
@@ -179,6 +192,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
thumbnailLoaded: Boolean,
|
thumbnailLoaded: Boolean,
|
||||||
onThumbnailLoaded: () -> Unit,
|
onThumbnailLoaded: () -> Unit,
|
||||||
onThumbnailRatio: (Float) -> Unit,
|
onThumbnailRatio: (Float) -> Unit,
|
||||||
|
onThumbnailError: () -> Unit,
|
||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
colorFilter: ColorFilter?,
|
colorFilter: ColorFilter?,
|
||||||
placeholderContentColor: Color?,
|
placeholderContentColor: Color?,
|
||||||
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError = { onThumbnailError() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
|
|||||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberRestorableLazyListState(contentReady)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
|
|||||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||||
|
|
||||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val index = listState.firstVisibleItemIndex
|
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
|
@Composable
|
||||||
private fun HeroSubtitle(
|
private fun HeroSubtitle(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -291,7 +323,11 @@ fun HeroHeader(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.heightIn(min = 32.dp)
|
.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,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.Album
|
import androidx.compose.material.icons.rounded.Album
|
||||||
import androidx.compose.material.icons.rounded.Cancel
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -197,7 +199,8 @@ fun ItemContextMenu(
|
|||||||
target: MenuTarget,
|
target: MenuTarget,
|
||||||
actions: ItemActions,
|
actions: ItemActions,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onInfo: ((SongModel) -> Unit)? = null,
|
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -210,6 +213,11 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceWith(after: () -> Unit) {
|
||||||
|
onDismiss()
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
|
||||||
val queueRef = target.queueRef()
|
val queueRef = target.queueRef()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -256,18 +264,9 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.song.artistRefs.forEach { artist ->
|
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
|
||||||
MenuItem(
|
|
||||||
Icons.Rounded.Person,
|
|
||||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artist.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onInfo?.let { info ->
|
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) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistRefs.forEach { artist ->
|
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
onInfo?.let { info ->
|
||||||
MenuItem(
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
Icons.Rounded.Person,
|
|
||||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artist.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +291,17 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MenuTarget.Playlist -> {
|
is MenuTarget.Playlist -> {
|
||||||
queueItems()
|
queueItems()
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
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
|
@Composable
|
||||||
private fun DownloadItem(
|
private fun DownloadItem(
|
||||||
download: SongDownload?,
|
download: SongDownload?,
|
||||||
@@ -439,11 +487,19 @@ private fun MenuItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
tint: Color? = null,
|
tint: Color? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(label) },
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||||
|
trailingContent = trailing,
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
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
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
|
||||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
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()
|
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 }
|
val primary = artist?.takeIf { it in names }
|
||||||
return buildList {
|
return buildList {
|
||||||
primary?.let(::add)
|
primary?.let(::add)
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
|||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
|||||||
title = { Text(stringResource(R.string.info)) },
|
title = { Text(stringResource(R.string.info)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
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_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.resources_filter_album), song.album)
|
||||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||||
InfoRow(
|
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
|
@Composable
|
||||||
private fun downloadStatus(status: DownloadStatus): String =
|
private fun downloadStatus(status: DownloadStatus): String =
|
||||||
when (status) {
|
when (status) {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
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.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -108,6 +110,7 @@ fun HomeListRoute(
|
|||||||
genres = genres,
|
genres = genres,
|
||||||
decades = decades,
|
decades = decades,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@@ -133,6 +136,7 @@ fun HomeListScreen(
|
|||||||
genres: List<String> = emptyList(),
|
genres: List<String> = emptyList(),
|
||||||
decades: List<Long> = emptyList(),
|
decades: List<Long> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -191,7 +195,7 @@ fun HomeListScreen(
|
|||||||
OfflineListKind.Artists -> {
|
OfflineListKind.Artists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -245,7 +249,7 @@ fun HomeListScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistsList(
|
private fun ArtistsList(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
info: (Artist) -> String?,
|
info: (Artist) -> String?,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
@@ -460,12 +464,13 @@ private fun ArtistsList(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = info(artist),
|
info = info(artist),
|
||||||
onClick = { onArtistClick(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
|
@Composable
|
||||||
internal fun ArtistListRow(
|
internal fun ArtistListRow(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
art: ArtistArt,
|
||||||
info: String?,
|
info: String?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
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.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -156,6 +158,11 @@ class HomeListViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
val genre = request.genre
|
val genre = request.genre
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
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.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
|||||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
feed = feed,
|
feed = feed,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -173,6 +177,7 @@ fun HomeScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists,
|
HomeSection.RecentlyPlayedArtists,
|
||||||
feed.recentlyPlayedArtists,
|
feed.recentlyPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
|||||||
HomeSection.MostPlayedArtists,
|
HomeSection.MostPlayedArtists,
|
||||||
feed.mostPlayedArtists,
|
feed.mostPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
|||||||
) {
|
) {
|
||||||
homeTitle()
|
homeTitle()
|
||||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
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) }
|
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||||
songList(
|
songList(
|
||||||
key = "downloaded-songs",
|
key = "downloaded-songs",
|
||||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
|||||||
|
|
||||||
private fun LazyListScope.downloadedArtistRow(
|
private fun LazyListScope.downloadedArtistRow(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: () -> Unit,
|
onMore: () -> Unit,
|
||||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
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,
|
section: HomeSection,
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: (HomeSection) -> Unit,
|
onMore: (HomeSection) -> Unit,
|
||||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
|||||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTile(
|
private fun ArtistTile(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
ref: CoverArtRef?,
|
art: ArtistArt,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
|||||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = ref,
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
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.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -127,6 +129,11 @@ class HomeViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun playStarred(song: Song) {
|
fun playStarred(song: Song) {
|
||||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ fun AlbumDetailRoute(
|
|||||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
@@ -215,6 +215,7 @@ fun AlbumDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = album?.name.orEmpty(),
|
title = album?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.isNotEmpty(),
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
|||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
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 }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, 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()
|
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.WindowInsets
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
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.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
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.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.Shadow
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
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.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.model.ListDownloadStatus
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
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.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
@@ -121,7 +134,11 @@ import kotlinx.coroutines.launch
|
|||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import kotlin.math.PI
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
private val ART_HEIGHT = 420.dp
|
private val ART_HEIGHT = 420.dp
|
||||||
private val TITLE_INSET = 16.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 SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
||||||
private val BIO_ACTION_RESERVE = 104.dp
|
private val BIO_ACTION_RESERVE = 104.dp
|
||||||
private val BIO_ACTION_HEIGHT = 48.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_TOP_GAP = 8.dp
|
||||||
|
private val BIO_BOTTOM_GAP = 8.dp
|
||||||
|
|
||||||
internal fun estimateScrollPx(
|
internal fun estimateScrollPx(
|
||||||
index: Int,
|
index: Int,
|
||||||
@@ -176,9 +195,9 @@ fun ArtistDetailRoute(
|
|||||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||||
val bioSnapshots =
|
val bioSnapshots =
|
||||||
remember(viewModel) {
|
remember(viewModel) {
|
||||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||||
@@ -193,6 +212,7 @@ fun ArtistDetailRoute(
|
|||||||
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||||
|
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
@@ -232,6 +252,7 @@ fun ArtistDetailRoute(
|
|||||||
bioExpanded = bioExpanded,
|
bioExpanded = bioExpanded,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
|
heroArt = heroArt,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -271,6 +292,7 @@ fun ArtistDetailScreen(
|
|||||||
bioExpanded: Boolean = false,
|
bioExpanded: Boolean = false,
|
||||||
art: CoverArtRef?,
|
art: CoverArtRef?,
|
||||||
artThumbnail: CoverArtRef? = null,
|
artThumbnail: CoverArtRef? = null,
|
||||||
|
heroArt: ArtistArt? = null,
|
||||||
artwork: ArtworkColors? = null,
|
artwork: ArtworkColors? = null,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -284,7 +306,7 @@ fun ArtistDetailScreen(
|
|||||||
onCollapseBio: () -> Unit = {},
|
onCollapseBio: () -> Unit = {},
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val lineHeight =
|
val lineHeight =
|
||||||
@@ -331,8 +353,8 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -388,11 +410,9 @@ fun ArtistDetailScreen(
|
|||||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
CoverArt(
|
ArtistHeroArt(
|
||||||
ref = art,
|
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
|
||||||
name = displayName,
|
name = displayName,
|
||||||
thumbnailRef = artThumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||||
)
|
)
|
||||||
if (nameScrimAlpha > 0f) {
|
if (nameScrimAlpha > 0f) {
|
||||||
@@ -470,7 +490,7 @@ fun ArtistDetailScreen(
|
|||||||
onCollapse = onCollapseBio,
|
onCollapse = onCollapseBio,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.padding(
|
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
|
@Composable
|
||||||
// full width.
|
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
|
@Composable
|
||||||
private fun ArtistBio(
|
private fun ArtistBio(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -587,10 +631,28 @@ private fun ArtistBio(
|
|||||||
.toInt()
|
.toInt()
|
||||||
.coerceAtLeast(1)
|
.coerceAtLeast(1)
|
||||||
val bioLines =
|
val bioLines =
|
||||||
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) {
|
remember(
|
||||||
|
text,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
collapsed,
|
||||||
|
expanded,
|
||||||
|
moreText,
|
||||||
|
lessText,
|
||||||
|
) {
|
||||||
when {
|
when {
|
||||||
collapsed -> {
|
collapsed -> {
|
||||||
splitBioCollapsed(measurer, text, style, fullWidth, reserve, reserveLines, moreText, maxLines = 3)
|
splitBioCollapsed(
|
||||||
|
measurer,
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
moreText,
|
||||||
|
maxLines = 3,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
expanded -> {
|
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)
|
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.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -40,23 +38,26 @@ class ArtistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
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?> =
|
val artistName: StateFlow<String?> =
|
||||||
sourceId.flatMapLatest { id ->
|
sourceId
|
||||||
|
.flatMapLatest { id ->
|
||||||
combine(
|
combine(
|
||||||
libraryRepository.artist(id, artistId),
|
libraryRepository.artist(id, artistId),
|
||||||
libraryRepository.artistName(id, artistId),
|
libraryRepository.artistName(id, artistId),
|
||||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||||
}
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val albums: Flow<ArtistAlbums?> =
|
val albums: StateFlow<ArtistAlbums?> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||||
.map<ArtistAlbums, ArtistAlbums?> { it }
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
.onStart { emit(null) }
|
|
||||||
|
|
||||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
sourceId
|
||||||
@@ -68,7 +69,10 @@ class ArtistDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.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)
|
private val _bioLoading = MutableStateFlow(true)
|
||||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||||
@@ -136,30 +140,36 @@ class ArtistDetailViewModel(
|
|||||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||||
|
|
||||||
|
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||||
|
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
artist
|
sourceRepository.activeSource
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.collectLatest { loaded ->
|
.flatMapLatest { sourceId ->
|
||||||
val (full, thumbnail) = resolveArt(loaded)
|
combine(
|
||||||
_art.value = full
|
libraryRepository.artist(sourceId, artistId),
|
||||||
_artThumbnail.value = thumbnail
|
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(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
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.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
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.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.ListDownloadIndicator
|
import com.subtracks.ui.components.ListDownloadIndicator
|
||||||
@@ -55,6 +56,7 @@ fun ArtistsContent(
|
|||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
scrollToTopKey: Any? = null,
|
scrollToTopKey: Any? = null,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
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 ->
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
val artist = items[index]
|
val artist = items[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
val art = artistArt(artist)
|
||||||
|
PrefetchArtworkSeeds(art.primary)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
@@ -104,9 +107,9 @@ fun ArtistsContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -117,7 +120,7 @@ fun ArtistsContent(
|
|||||||
Modifier.combinedClickable(
|
Modifier.combinedClickable(
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = {
|
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),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.material.icons.rounded.Settings
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
@@ -71,7 +74,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
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.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -120,6 +124,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
@@ -308,6 +313,7 @@ fun LibraryRoute(
|
|||||||
artists = viewModel.artists,
|
artists = viewModel.artists,
|
||||||
playlists = viewModel.playlists,
|
playlists = viewModel.playlists,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -396,6 +402,7 @@ fun LibraryScreen(
|
|||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
onOpenSearch: () -> Unit = {},
|
onOpenSearch: () -> Unit = {},
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
@@ -539,6 +546,7 @@ fun LibraryScreen(
|
|||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
scrollToTopKey = scrollToTopKey,
|
scrollToTopKey = scrollToTopKey,
|
||||||
|
artistArt = artistArt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,8 +596,6 @@ fun LibraryScreen(
|
|||||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
},
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
|
||||||
onExitOffline = onExitOffline,
|
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
@@ -611,11 +617,29 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchActive) {
|
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
|
val searchFab = selectedTab == LibraryTab.Home
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = fabContainerColor,
|
||||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
contentColor = fabContentColor,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||||
) {
|
) {
|
||||||
@@ -634,10 +658,16 @@ fun LibraryScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.offset(x = 4.dp, y = (-4).dp)
|
.offset(x = 1.dp, y = 2.5.dp)
|
||||||
.size(8.dp)
|
.size(11.dp)
|
||||||
.clip(CircleShape)
|
.background(fabContainerColor, CircleShape),
|
||||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.background(fabContentColor, CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +675,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showOptions) {
|
if (showOptions) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -698,7 +729,7 @@ private fun ListOptionsSheet(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
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)
|
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(stringResource(R.string.search_this_list), color = 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 BOTTOM_CLEARANCE = 80.dp
|
||||||
private val TAB_BAR_CONTENT_HEIGHT = 52.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_ICON_SIZE = 24.dp
|
||||||
private val TAB_VERTICAL_PADDING = 6.dp
|
private val TAB_VERTICAL_PADDING = 6.dp
|
||||||
private val TOUCH_TARGET = 48.dp
|
private val TOUCH_TARGET = 48.dp
|
||||||
@@ -852,13 +884,12 @@ internal fun LibraryTabs(
|
|||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
offline: Boolean = false,
|
|
||||||
onExitOffline: () -> Unit = {},
|
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||||
val page = pagerState.currentPage
|
val page = pagerState.currentPage
|
||||||
val fraction = pagerState.currentPageOffsetFraction
|
val fraction = pagerState.currentPageOffsetFraction
|
||||||
@@ -890,18 +921,61 @@ internal fun LibraryTabs(
|
|||||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
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.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(headerColor)
|
.background(headerColor)
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onSizeChanged { viewportWidth = it.width }
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.onSizeChanged { contentWidth = it.width },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.drawBehind {
|
Modifier
|
||||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
.drawBehind {
|
||||||
|
if (current != null &&
|
||||||
|
indicatorLeft != null &&
|
||||||
|
indicatorRight != null &&
|
||||||
|
indicatorRight > indicatorLeft
|
||||||
|
) {
|
||||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = indicatorColor,
|
color = indicatorColor,
|
||||||
@@ -913,7 +987,6 @@ internal fun LibraryTabs(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
@@ -936,14 +1009,7 @@ internal fun LibraryTabs(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
@@ -965,7 +1031,6 @@ internal fun LibraryTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -211,6 +213,11 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun sync() {
|
fun sync() {
|
||||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
|
|||||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = playlist?.name.orEmpty(),
|
title = playlist?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.itemCount > 0,
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
art = coverArt(playlist?.coverArt, false),
|
art = coverArt(playlist?.coverArt, false),
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
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>> =
|
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
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.data.model.CoverArtRef
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.playerSurfaceColor
|
import com.subtracks.ui.theme.playerSurfaceColor
|
||||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
|||||||
modifier = Modifier.basicMarquee(),
|
modifier = Modifier.basicMarquee(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.artist ?: item.album.orEmpty(),
|
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost
|
|||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
@@ -576,7 +577,10 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||||
Text(
|
Text(
|
||||||
text = state.item?.artist.orEmpty(),
|
text =
|
||||||
|
state.item
|
||||||
|
?.let { artistSubtitle(it.artist, it.artists) }
|
||||||
|
.orEmpty(),
|
||||||
style = subtitleStyle,
|
style = subtitleStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -106,6 +107,7 @@ fun SearchRoute(
|
|||||||
if (next != 0) filterMask = next
|
if (next != 0) filterMask = next
|
||||||
},
|
},
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = {
|
onAlbumClick = {
|
||||||
@@ -146,6 +148,7 @@ fun SearchScreen(
|
|||||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -215,7 +218,7 @@ fun SearchScreen(
|
|||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
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 (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||||
}
|
}
|
||||||
@@ -308,19 +311,20 @@ private fun LazyListScope.albumResults(
|
|||||||
private fun LazyListScope.artistResults(
|
private fun LazyListScope.artistResults(
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
header: String,
|
header: String,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onClick: (Artist) -> Unit,
|
onClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (artists.isEmpty()) return
|
if (artists.isEmpty()) return
|
||||||
resultHeader(header)
|
resultHeader(header)
|
||||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = null,
|
info = null,
|
||||||
onClick = { onClick(artist) },
|
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.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import com.subtracks.ui.home.playSongInContext
|
import com.subtracks.ui.home.playSongInContext
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -74,6 +76,11 @@ class SearchViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
viewModelScope.playSongInContext(libraryRepository, playbackController, 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.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
import com.subtracks.data.prefs.MIN_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.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
|
|||||||
SyncMode,
|
SyncMode,
|
||||||
ReplayGain,
|
ReplayGain,
|
||||||
ReplayGainPreamp,
|
ReplayGainPreamp,
|
||||||
|
SortNames,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -128,6 +130,7 @@ fun SettingsRoute(
|
|||||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||||
|
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -143,6 +146,7 @@ fun SettingsRoute(
|
|||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
replayGainMode = replayGainMode,
|
replayGainMode = replayGainMode,
|
||||||
replayGainPreamp = replayGainPreamp,
|
replayGainPreamp = replayGainPreamp,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
verboseLogging = verboseLogging,
|
verboseLogging = verboseLogging,
|
||||||
@@ -158,6 +162,7 @@ fun SettingsRoute(
|
|||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||||
|
onSortNameModeChange = viewModel::setSortNameMode,
|
||||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
@@ -205,6 +210,7 @@ fun SettingsScreen(
|
|||||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||||
replayGainPreamp: Float = 0f,
|
replayGainPreamp: Float = 0f,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
@@ -217,6 +223,7 @@ fun SettingsScreen(
|
|||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||||
|
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
@@ -348,6 +355,15 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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 { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -603,6 +619,14 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SortNames -> {
|
||||||
|
SortNameModeDialog(
|
||||||
|
selected = sortNameMode,
|
||||||
|
onSelect = onSortNameModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
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
|
@Composable
|
||||||
private fun ReplayGainModeDialog(
|
private fun ReplayGainModeDialog(
|
||||||
selected: ReplayGainMode,
|
selected: ReplayGainMode,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.ReplayGainMode
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
@@ -76,6 +77,11 @@ class SettingsViewModel(
|
|||||||
.replayGainPreampDb()
|
.replayGainPreampDb()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
.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> =
|
val keepScreenOn: StateFlow<Boolean> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.keepScreenOn()
|
.keepScreenOn()
|
||||||
@@ -143,4 +149,8 @@ class SettingsViewModel(
|
|||||||
fun setKeepScreenOn(enabled: Boolean) {
|
fun setKeepScreenOn(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
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">Verbose logging</string>
|
||||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</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_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_library_name">Library</string>
|
||||||
<string name="settings_music_name">Playback</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_description_on">Send now playing and play counts</string>
|
||||||
<string name="settings_music_options_scrobble_title">Scrobble to server</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_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_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_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_name">Network</string>
|
||||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</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>
|
<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_sample_rate_value">%1$d Hz</string>
|
||||||
<string name="info_channels">Channels</string>
|
<string name="info_channels">Channels</string>
|
||||||
<string name="info_close">Close</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_header">Disc %1$d</string>
|
||||||
<string name="disc_track">%1$s: Disc %2$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.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@@ -332,10 +333,18 @@ class LibraryDaoTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val orders =
|
val orders =
|
||||||
listOf(
|
listOf(
|
||||||
ALBUM_ORDER_BY_NAME to "index_albums_name",
|
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_name",
|
||||||
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name",
|
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_name",
|
||||||
ALBUM_ORDER_BY_ARTIST to "index_albums_artist",
|
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_name_plain",
|
||||||
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist",
|
"${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 to "index_albums_year",
|
||||||
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
|
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
|
||||||
ALBUM_ORDER_BY_ADDED to "index_albums_added",
|
ALBUM_ORDER_BY_ADDED to "index_albums_added",
|
||||||
@@ -359,8 +368,10 @@ class LibraryDaoTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val orders =
|
val orders =
|
||||||
listOf(
|
listOf(
|
||||||
ARTIST_ORDER_BY_NAME to "index_artists_name",
|
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
|
||||||
ARTIST_ORDER_BY_NAME_REVERSED 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 to "index_artists_albumCount",
|
||||||
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||||
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||||
@@ -548,7 +559,7 @@ class LibraryDaoTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
|
fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
|
||||||
runTest {
|
runTest {
|
||||||
val sourceId = source()
|
val sourceId = source()
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
@@ -615,40 +626,241 @@ class LibraryDaoTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dao.recomputeArtistArt(sourceId)
|
||||||
|
|
||||||
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
|
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
|
||||||
|
|
||||||
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
||||||
assertEquals("new-art", artists.getValue("ar-album").coverArt)
|
assertNull(artists.getValue("ar-album").coverArt)
|
||||||
assertEquals("track-art", artists.getValue("ar-track").coverArt)
|
assertNull(artists.getValue("ar-track").coverArt)
|
||||||
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt)
|
assertNull(artists.getValue("ar-album-ref").coverArt)
|
||||||
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt)
|
assertNull(artists.getValue("ar-track-ref").coverArt)
|
||||||
assertNull(artists.getValue("ar-null-art").coverArt)
|
assertNull(artists.getValue("ar-null-art").coverArt)
|
||||||
assertNull(artists.getValue("ar-none").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(
|
assertEquals(
|
||||||
"album-ref-art",
|
listOf("album-ref-art"),
|
||||||
dao
|
dao
|
||||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "ar-album-ref" }
|
.first { it.id == "ar-album-ref" }
|
||||||
.coverArt,
|
.coverArts,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
|
fun coverArtFallbackFindsEveryCreditPath() =
|
||||||
runTest {
|
runTest {
|
||||||
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20")
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
dao.upsertAlbums(
|
||||||
assertTrue(
|
listOf(
|
||||||
plan,
|
album(sourceId, "al-1", "Album", year = 2020, starred = null)
|
||||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
.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=?)"))
|
dao.upsertSongs(
|
||||||
assertTrue(
|
listOf(
|
||||||
plan,
|
song(sourceId, "s-1", "Song", starred = null)
|
||||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
.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 {
|
runTest {
|
||||||
val sourceId = source()
|
val sourceId = source()
|
||||||
val dao = db.libraryDao()
|
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(
|
dao.upsertSongs(
|
||||||
listOf(
|
listOf(
|
||||||
song(sourceId, "s1", "One", starred = null),
|
song(sourceId, "s1", "One", starred = null),
|
||||||
@@ -822,6 +1039,8 @@ class LibraryDaoTest {
|
|||||||
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
|
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
|
||||||
assertEquals(1, filtered.size)
|
assertEquals(1, filtered.size)
|
||||||
assertEquals(1L, filtered.first().songCount)
|
assertEquals(1L, filtered.first().songCount)
|
||||||
|
assertEquals("austin", filtered.first().owner)
|
||||||
|
assertEquals(true, filtered.first().shared)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
5L,
|
5L,
|
||||||
@@ -935,7 +1154,7 @@ class LibraryDaoTest {
|
|||||||
val plan =
|
val plan =
|
||||||
plan(
|
plan(
|
||||||
"SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " +
|
"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"))
|
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
|
@Test
|
||||||
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
|
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
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
|
@Test
|
||||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -395,6 +501,16 @@ class MigrationsTest {
|
|||||||
statement.getLong(0)
|
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(
|
private fun indexColumn(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
index: String,
|
index: String,
|
||||||
@@ -428,6 +544,10 @@ class MigrationsTest {
|
|||||||
for (i in 0 until indices.length()) {
|
for (i in 0 until indices.length()) {
|
||||||
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
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() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
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
|
@After
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ class UserPreferencesTest {
|
|||||||
file.delete()
|
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
|
@Test
|
||||||
fun theSelectedLibraryTabRoundTrips() =
|
fun theSelectedLibraryTabRoundTrips() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
|
|||||||
private val requestedArt = CopyOnWriteArrayList<String>()
|
private val requestedArt = CopyOnWriteArrayList<String>()
|
||||||
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
||||||
private var failArtworkFetch = false
|
private var failArtworkFetch = false
|
||||||
|
private val failingArtUrls = CopyOnWriteArrayList<String>()
|
||||||
private val fetcher =
|
private val fetcher =
|
||||||
ArtworkFetcher { url ->
|
ArtworkFetcher { url ->
|
||||||
requestedArt += url
|
requestedArt += url
|
||||||
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
|
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
|
||||||
if (failArtworkFetch) throw IOException("boom")
|
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
|
||||||
byteArrayOf(1, 2, 3)
|
byteArrayOf(1, 2, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +346,10 @@ class DownloadRepositoryTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
writeFile(1, "s1", "audio")
|
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
|
val id = engine.requests.single().first
|
||||||
|
|
||||||
runBlocking { repository.removeSource(1) }
|
runBlocking { repository.removeSource(1) }
|
||||||
@@ -352,6 +357,13 @@ class DownloadRepositoryTest {
|
|||||||
assertTrue(engine.cancelled.contains(id))
|
assertTrue(engine.cancelled.contains(id))
|
||||||
assertFalse(file(1, "s1").exists())
|
assertFalse(file(1, "s1").exists())
|
||||||
assertTrue(allRows().isEmpty())
|
assertTrue(allRows().isEmpty())
|
||||||
|
assertTrue(
|
||||||
|
db
|
||||||
|
.libraryDao()
|
||||||
|
.artistOnce(1, "ar1")!!
|
||||||
|
.downloadedCoverArts
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
@Test
|
||||||
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
|
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -1282,8 +1383,19 @@ class DownloadRepositoryTest {
|
|||||||
songCount = 2,
|
songCount = 2,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun artist(coverArt: String?) =
|
private fun artist(
|
||||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
|
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/") {
|
private fun seedLibrary(address: String = "http://localhost:4533/") {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class LibraryRepositoryTest {
|
|||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
|
|||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
|||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -283,6 +285,35 @@ class SourceRepositoryTest {
|
|||||||
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
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 awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
|
||||||
|
|
||||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
|
|||||||
"""
|
"""
|
||||||
<subsonic-response status="ok">
|
<subsonic-response status="ok">
|
||||||
<artists>
|
<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"/>
|
<artist id="ar2" name="Portishead" albumCount="3"/>
|
||||||
</artists>
|
</artists>
|
||||||
</subsonic-response>
|
</subsonic-response>
|
||||||
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(7L, artists[0].sourceId)
|
assertEquals(7L, artists[0].sourceId)
|
||||||
assertEquals(9L, artists[0].albumCount)
|
assertEquals(9L, artists[0].albumCount)
|
||||||
assertEquals(1577934245L, artists[0].starred)
|
assertEquals(1577934245L, artists[0].starred)
|
||||||
|
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
|
||||||
|
assertEquals(null, artists[1].artistImageUrl)
|
||||||
assertEquals(null, artists[1].starred)
|
assertEquals(null, artists[1].starred)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,6 +745,29 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
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(
|
private class CountingInputStream(
|
||||||
input: InputStream,
|
input: InputStream,
|
||||||
) : FilterInputStream(input) {
|
) : FilterInputStream(input) {
|
||||||
|
|||||||
@@ -258,6 +258,19 @@ class SyncServiceTest {
|
|||||||
assertEquals(5678L, stored?.starred)
|
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
|
@Test
|
||||||
fun syncStoresSongGenresInOrder() =
|
fun syncStoresSongGenresInOrder() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.subtracks.playback
|
|||||||
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -70,6 +71,21 @@ class MediaItemTest {
|
|||||||
assertEquals(gain, item.replayGain())
|
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
|
@Test
|
||||||
fun theItemCarriesItsGainContext() {
|
fun theItemCarriesItsGainContext() {
|
||||||
val item =
|
val item =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -57,6 +58,29 @@ class QueueItemTest {
|
|||||||
assertEquals(true, item.albumContext)
|
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
|
@Test
|
||||||
fun aSongWithoutGainCarriesNoGain() {
|
fun aSongWithoutGainCarriesNoGain() {
|
||||||
val item =
|
val item =
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
|
|||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.playback.RepeatMode
|
import com.subtracks.playback.RepeatMode
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
|
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
|
||||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||||
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_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")
|
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) {
|
private fun renderArtistDetail(bioExpanded: Boolean) {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
@@ -584,6 +617,16 @@ class AppScreenshotTest {
|
|||||||
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
|
||||||
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
|
||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
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 = {},
|
onAlbumClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onPlaylistClick = {},
|
onPlaylistClick = {},
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.data.repo.SEARCH_RESULT_LIMIT
|
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.SubsonicSource
|
||||||
import com.subtracks.data.source.subsonic.TestServer
|
import com.subtracks.data.source.subsonic.TestServer
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import com.subtracks.data.sync.SyncService
|
import com.subtracks.data.sync.SyncService
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.home.HomeFeed
|
import com.subtracks.ui.home.HomeFeed
|
||||||
import com.subtracks.ui.home.HomeScreen
|
import com.subtracks.ui.home.HomeScreen
|
||||||
import com.subtracks.ui.library.AlbumDetailScreen
|
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`.
|
* 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
|
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
|
||||||
* so reruns do not re-fetch it.
|
* 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)
|
@OptIn(DelicateCoilApi::class)
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
@Config(qualifiers = "w393dp-h786dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||||
class DemoScreenshotTest {
|
class DemoScreenshotTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
@@ -123,6 +129,7 @@ class DemoScreenshotTest {
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
feed = demo.feed,
|
feed = demo.feed,
|
||||||
coverArt = demo.coverArt,
|
coverArt = demo.coverArt,
|
||||||
|
artistArt = demo::artistArt,
|
||||||
playingSongId = demo.homePlayingSong.id,
|
playingSongId = demo.homePlayingSong.id,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -178,6 +185,7 @@ class DemoScreenshotTest {
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = demo.artist,
|
artist = demo.artist,
|
||||||
artistName = demo.artist.name,
|
artistName = demo.artist.name,
|
||||||
|
bio = demo.artistBio,
|
||||||
albums =
|
albums =
|
||||||
ArtistAlbums(
|
ArtistAlbums(
|
||||||
albumCredits =
|
albumCredits =
|
||||||
@@ -216,6 +224,7 @@ class DemoScreenshotTest {
|
|||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
||||||
|
artistArt = demo::artistArt,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
||||||
@@ -228,6 +237,7 @@ class DemoScreenshotTest {
|
|||||||
onQueryChange = {},
|
onQueryChange = {},
|
||||||
results = demo.search,
|
results = demo.search,
|
||||||
coverArt = demo.coverArt,
|
coverArt = demo.coverArt,
|
||||||
|
artistArt = demo::artistArt,
|
||||||
playingSongId = demo.nowPlayingSong.id,
|
playingSongId = demo.nowPlayingSong.id,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -288,7 +298,7 @@ class DemoScreenshotTest {
|
|||||||
|
|
||||||
private fun reencodeOpaqueRgb(file: File) {
|
private fun reencodeOpaqueRgb(file: File) {
|
||||||
val decoded = ImageIO.read(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 {
|
rgb.createGraphics().run {
|
||||||
drawImage(decoded, 0, 0, null)
|
drawImage(decoded, 0, 0, null)
|
||||||
dispose()
|
dispose()
|
||||||
@@ -329,6 +339,7 @@ class DemoScreenshotTest {
|
|||||||
val homePlayingAlbum: Album,
|
val homePlayingAlbum: Album,
|
||||||
val homePlayingSong: Song,
|
val homePlayingSong: Song,
|
||||||
val artist: Artist,
|
val artist: Artist,
|
||||||
|
val artistBio: String?,
|
||||||
val artistAlbums: List<Album>,
|
val artistAlbums: List<Album>,
|
||||||
val search: SearchResults,
|
val search: SearchResults,
|
||||||
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
@@ -371,6 +382,15 @@ class DemoScreenshotTest {
|
|||||||
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
|
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 {
|
companion object {
|
||||||
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
||||||
private const val DEMO_BASE = "https://demo.navidrome.org/"
|
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 homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
|
||||||
val homePlayingSong = songsOf(homePlayingAlbum).first()
|
val homePlayingSong = songsOf(homePlayingAlbum).first()
|
||||||
val artist =
|
val artist =
|
||||||
allArtists.firstOrNull { it.name == "Ugress" }
|
allArtists.firstOrNull { it.name == "Nine Inch Nails" }
|
||||||
|
?: allArtists.firstOrNull { it.name == "pornophonique" }
|
||||||
?: allArtists.maxByOrNull { it.albumCount }
|
?: allArtists.maxByOrNull { it.albumCount }
|
||||||
?: error("demo library has no artists")
|
?: error("demo library has no artists")
|
||||||
|
val artistBio = normalizeBiography(source.artistBiography(artist.id))
|
||||||
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
||||||
|
|
||||||
val coverArtIds =
|
val coverArtIds =
|
||||||
@@ -509,6 +531,7 @@ class DemoScreenshotTest {
|
|||||||
homePlayingAlbum = homePlayingAlbum,
|
homePlayingAlbum = homePlayingAlbum,
|
||||||
homePlayingSong = homePlayingSong,
|
homePlayingSong = homePlayingSong,
|
||||||
artist = artist,
|
artist = artist,
|
||||||
|
artistBio = artistBio,
|
||||||
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
||||||
search = search,
|
search = search,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.subtracks.ui
|
|||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
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.swipeDown
|
||||||
import androidx.compose.ui.test.swipeLeft
|
import androidx.compose.ui.test.swipeLeft
|
||||||
import androidx.compose.ui.test.swipeRight
|
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.LoadState
|
||||||
import androidx.paging.LoadStates
|
import androidx.paging.LoadStates
|
||||||
import androidx.paging.PagingData
|
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)
|
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 =
|
private val settledLoadStates =
|
||||||
LoadStates(
|
LoadStates(
|
||||||
refresh = LoadState.NotLoading(endOfPaginationReached = true),
|
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.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
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.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.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -44,34 +47,69 @@ class ItemContextMenuArtistTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
|
fun pluralArtistsShareOneEntryThatOpensTheChooser() {
|
||||||
var opened: String? = null
|
var opened: List<ArtistRef>? = null
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
var show by remember { mutableStateOf(true) }
|
var show by remember { mutableStateOf(true) }
|
||||||
if (show) {
|
if (show) {
|
||||||
ItemContextMenu(
|
ItemContextMenu(
|
||||||
target = MenuTarget.Song(song),
|
target = MenuTarget.Song(song),
|
||||||
actions = ItemActions(viewArtist = { opened = it }),
|
actions = ItemActions(viewArtist = {}),
|
||||||
onDismiss = { show = false },
|
onDismiss = { show = false },
|
||||||
|
onChooseArtists = { opened = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction())
|
||||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
entry.assertIsDisplayed()
|
||||||
|
assertTrue(
|
||||||
|
composeRule
|
||||||
|
.onAllNodesWithText("A")
|
||||||
|
.filter(hasClickAction())
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
composeRule
|
||||||
|
.onAllNodesWithText("B")
|
||||||
|
.filter(hasClickAction())
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
composeRule.mainClock.autoAdvance = false
|
composeRule.mainClock.autoAdvance = false
|
||||||
composeRule.onNodeWithText("B").performClick()
|
entry.performClick()
|
||||||
composeRule.mainClock.advanceTimeBy(1_000)
|
|
||||||
|
|
||||||
assertEquals("ar2", opened)
|
assertEquals(listOf("ar1", "ar2"), opened?.map { it.id })
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun longMenusScrollToTheLastArtist() {
|
fun aSingleArtistOpensDirectly() {
|
||||||
var opened: String? = null
|
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 {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
ItemContextMenu(
|
ItemContextMenu(
|
||||||
@@ -79,18 +117,23 @@ class ItemContextMenuArtistTest {
|
|||||||
MenuTarget.Song(
|
MenuTarget.Song(
|
||||||
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
||||||
),
|
),
|
||||||
actions = ItemActions(viewArtist = { opened = it }),
|
actions = ItemActions(viewArtist = {}),
|
||||||
onDismiss = {},
|
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.mainClock.autoAdvance = false
|
||||||
composeRule.onNodeWithText("Artist 20").performClick()
|
entry.performClick()
|
||||||
composeRule.mainClock.advanceTimeBy(1_000)
|
|
||||||
|
|
||||||
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 =
|
viewModel =
|
||||||
DownloadsViewModel(
|
DownloadsViewModel(
|
||||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
LibraryRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
|
scope = repoScope,
|
||||||
|
),
|
||||||
downloadRepository,
|
downloadRepository,
|
||||||
controller,
|
controller,
|
||||||
"Unknown artist",
|
"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) },
|
artworkFetcher = { ByteArray(0) },
|
||||||
scope = repoScope,
|
scope = repoScope,
|
||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
library = LibraryRepository(db, sources, serverActions, scope = repoScope)
|
library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
|
||||||
handle = FakePlayerHandle()
|
handle = FakePlayerHandle()
|
||||||
controller =
|
controller =
|
||||||
PlaybackController(
|
PlaybackController(
|
||||||
|
|||||||
@@ -90,7 +90,13 @@ class SearchViewModelTest {
|
|||||||
)
|
)
|
||||||
viewModel =
|
viewModel =
|
||||||
SearchViewModel(
|
SearchViewModel(
|
||||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
LibraryRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
|
scope = repoScope,
|
||||||
|
),
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
controller,
|
controller,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
|
|||||||
import androidx.compose.ui.test.performScrollToNode
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -31,7 +32,7 @@ class SettingsScreenTest {
|
|||||||
@Test
|
@Test
|
||||||
fun theKeepScreenOnRowTurnsTheSettingOn() {
|
fun theKeepScreenOnRowTurnsTheSettingOn() {
|
||||||
var requested: Boolean? = null
|
var requested: Boolean? = null
|
||||||
renderSettings(keepScreenOn = false) { requested = it }
|
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||||
|
|
||||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
@@ -42,7 +43,7 @@ class SettingsScreenTest {
|
|||||||
@Test
|
@Test
|
||||||
fun theKeepScreenOnRowTurnsTheSettingOff() {
|
fun theKeepScreenOnRowTurnsTheSettingOff() {
|
||||||
var requested: Boolean? = null
|
var requested: Boolean? = null
|
||||||
renderSettings(keepScreenOn = true) { requested = it }
|
renderSettings(keepScreenOn = true, onKeepScreenOnChange = { requested = it })
|
||||||
|
|
||||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||||
composeRule.waitForIdle()
|
composeRule.waitForIdle()
|
||||||
@@ -53,7 +54,7 @@ class SettingsScreenTest {
|
|||||||
@Test
|
@Test
|
||||||
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
|
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
|
||||||
var requested: Boolean? = null
|
var requested: Boolean? = null
|
||||||
renderSettings(keepScreenOn = false) { requested = it }
|
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||||
|
|
||||||
composeRule
|
composeRule
|
||||||
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
|
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
|
||||||
@@ -63,9 +64,29 @@ class SettingsScreenTest {
|
|||||||
assertEquals(true, requested)
|
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(
|
private fun renderSettings(
|
||||||
keepScreenOn: Boolean,
|
keepScreenOn: Boolean,
|
||||||
onKeepScreenOnChange: (Boolean) -> Unit,
|
onKeepScreenOnChange: (Boolean) -> Unit,
|
||||||
|
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||||
|
scrollTo: String = "Keep screen on",
|
||||||
) {
|
) {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
@@ -88,6 +109,7 @@ class SettingsScreenTest {
|
|||||||
onDownloadOverMeteredChange = {},
|
onDownloadOverMeteredChange = {},
|
||||||
onScrobblingChange = {},
|
onScrobblingChange = {},
|
||||||
onKeepScreenOnChange = onKeepScreenOnChange,
|
onKeepScreenOnChange = onKeepScreenOnChange,
|
||||||
|
onSortNameModeChange = onSortNameModeChange,
|
||||||
onAddServer = {},
|
onAddServer = {},
|
||||||
onOpenDownloads = {},
|
onOpenDownloads = {},
|
||||||
onOpenLicenses = {},
|
onOpenLicenses = {},
|
||||||
@@ -97,7 +119,7 @@ class SettingsScreenTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
awaitText("Add server")
|
awaitText("Add server")
|
||||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
|
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(scrollTo))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun awaitText(text: String) {
|
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 |