12 Commits
Author SHA1 Message Date
austinried 5de5b618d1 ui: let the third bio line use the full width
Only reserve the action-button width for the lines they actually overlap, so the collapsed bio's More stays on the third full-width line without moving the actions or spacing.
2026-10-20 13:47:36 +00:00
austinried 81d45ad0ad ui: flip the three-cover Y split
Turn the 3-cover layout upside down so the stem runs from the centre to the top edge and the two arms open toward the bottom, leaving one boundary straight up from the centre.
2026-10-20 13:36:59 +00:00
austinried 5583c9c1d6 ui: centre the artist actions with the shuffle button
Nudge the star and overflow row 8dp right, and restore the vertical offset, so the action row and shuffle button share a centre line. Keep the extra collapsed-bio spacing and the gap before the Albums header.
2026-10-20 13:16:24 +00:00
austinried 11a304e7ba ui: keep the artist actions aligned with the shuffle button
Move the breathing room into the bio text instead of offsetting the star and overflow actions, which broke their centre alignment with the shuffle button.
2026-10-20 13:12:51 +00:00
austinried c8de39acb9 ui: give the collapsed artist bio room from its actions
The three-line collapsed bio let its More control sit under the star and overflow buttons, and the collapsed state had no bottom gap before the Albums header. Move the actions up, reserve their width on all three collapsed lines, and add the same bottom gap the expanded state uses.
2026-10-20 13:01:47 +00:00
austinried 77e3fa87f1 library: keep artist art maintenance self-healing and cheap
Move change detection into a desired CTE with UPDATE ... FROM so mass updates do not turn the trailing WHERE into a quadratic correlated scan, and so no-op passes write zero rows. Fold the trigger optimization into the 35->36 migration and re-check/rewrite it from onOpen, so a future migration cannot silently restore the full UPDATE triggers. Re-resolve artist detail art when the active source becomes ready, and cover artist search renames in a Room-level test.
2026-10-20 11:23:02 +00:00
austinried 0a5b3512da library: stop artist art updates from reindexing artist search
The materialized art update rewrote every artist row on every library-changing sync. That fired the FTS5 content-sync triggers for each artist, which was the real cost: the same SQL is ~10ms with the triggers removed and ~4.7s for 100 changed artists with them in place. Only update rows whose cover lists actually changed, and replace the generated artist search triggers with UPDATE OF name, sortName so art-only writes never touch the FTS index. Existing databases get this through migration 36->37; fresh databases get it from the onCreate callback.
2026-10-20 10:13:20 +00:00
austinried 783d7f2ebc library: address artist art review findings
Cap the resolver at four covers, re-rank after deduplication so duplicate covers do not consume the grid budget, carry a thumbnail per fallback step, and advance to the grid when both the full image and its thumbnail fail. Negative-cache failed provider-image fetches per artist URL, clear materialized downloaded covers when all downloads are removed, and drop redundant plumbing.
2026-10-20 09:11:20 +00:00
austinried 18118492c7 library: materialize artist art and provider images
Store the OpenSubsonic artistImageUrl and two materialized cover-id lists on each artist row, backfill them in the 35->36 migration, and refresh downloaded covers only for affected artists when downloads change. Icons and the artist hero now resolve provider art, server artist art, then the same materialized grid synchronously, with a definitive-failure fallback so a dead provider URL does not strand the letter placeholder.

Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark.
2026-10-20 08:53:23 +00:00
austinried 0d1118011f ui: use the album grid for artist icons too
Load up to four thumbnail covers per visible artist row on demand and render the same 1-4 layout inside the circular artist icon. Lists stay on the expensive per-row query for now.
2026-10-20 07:27:42 +00:00
austinried 6e60ff6183 test: render the three-cover artist hero grid 2026-10-20 02:50:19 +00:00
austinried 4a85b23383 ui: prototype a multi-album artist hero fallback
When an artist has no portrait, build a 1-4 cover hero from album credits on demand. Uses full-res for one cover and thumbnails for 2-4; three covers use a Y split.
2026-10-20 02:48:18 +00:00
36 changed files with 3458 additions and 153 deletions
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(ArtistSearchTriggerOptimization)
.addCallback(SqliteLifecycleLogging)
.build()
}
@@ -114,6 +114,16 @@ class LoggedMigration(
}
}
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
optimizeArtistSearchTriggers(connection)
}
override suspend fun onOpen(connection: SQLiteConnection) {
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
}
}
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
Log.i("sql", "created fresh database")
@@ -162,12 +162,15 @@ interface DownloadDao {
@Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
"al.artistId AS albumArtistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
"aar.coverArt AS albumArtistCoverArt " +
"FROM song_downloads d " +
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
)
suspend fun artwork(): List<DownloadArtwork>
@@ -124,32 +124,138 @@ internal const val ALBUM_CREDIT_ALBUM_IDS =
// Download visibility follows the credit path: an album credit includes the album when any song is
// downloaded, while a track credit includes it only when the credited track is downloaded.
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albums.id FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
internal const val DOWNLOADED_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id FROM album_artists " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
"WHERE album_artists.sourceId = :sourceId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId FROM songs " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
"AND sd.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId FROM song_artists " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed'"
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
private const val DOWNLOADED_COVER_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
private const val ARTIST_COVER_CREDITS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
internal const val ARTIST_ART_UPDATE_SQL =
"WITH credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
"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), " +
"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 DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
"WHERE artistId IN (:artistIds)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"artists.downloadedCoverArts != desired.downloadedCoverArts"
internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " +
@@ -164,40 +270,10 @@ internal const val ARTISTS_FILTER =
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_ALBUM_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
"INDEXED BY index_albums_sourceId_artistId " +
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"UNION ALL " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
internal const val ARTIST_TRACK_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
"UNION ALL " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = artists.sourceId AND song_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
internal const val ARTIST_COVER_ART =
"COALESCE(artists.coverArt, CASE " +
"WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM album_artists " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " +
"THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)"
internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
"artists.sortName, artists.credited"
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
// With the Downloaded filter on, count the albums that actually have a download so the number under
// an artist matches the albums its detail screen lists.
@@ -206,8 +282,8 @@ internal const val ARTISTS_SELECT =
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " +
"artists.sortName, artists.credited "
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
@@ -334,10 +410,23 @@ interface LibraryDao {
)
suspend fun recomputeArtistAggregates(sourceId: Long)
@Query(ARTIST_ART_UPDATE_SQL)
suspend fun recomputeArtistArt(sourceId: Long)
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
artistIds: List<String>,
)
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
suspend fun clearDownloadedArtistArt(sourceId: Long)
@Transaction
suspend fun recomputeAggregates(sourceId: Long) {
recomputeAlbumPlayData(sourceId)
recomputeArtistAggregates(sourceId)
recomputeArtistArt(sourceId)
}
@Query(
@@ -381,6 +470,12 @@ interface LibraryDao {
songId: String,
): String?
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
suspend fun creditedArtistIdsForSongs(
sourceId: Long,
songIds: List<String>,
): List<String>
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
// sync then overwrites with the server's own number.
@Transaction
@@ -695,6 +695,56 @@ private suspend fun createSearchIndex(
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
}
val MIGRATION_35_36 =
object : Migration(35, 36) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
optimizeArtistSearchTriggers(connection)
backfillArtistArt(connection)
}
}
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
val triggers = mutableListOf<String>()
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
while (statement.step()) triggers += statement.getText(0)
}
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
}
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
)
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
)
}
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
val sourceIds = mutableListOf<Long>()
connection.prepare("SELECT id FROM sources").use { statement ->
while (statement.step()) sourceIds += statement.getLong(0)
}
sourceIds.forEach { sourceId ->
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -731,4 +781,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_32_33,
MIGRATION_33_34,
MIGRATION_34_35,
MIGRATION_35_36,
)
@@ -28,6 +28,7 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongGenre
import com.subtracks.data.model.SongSearch
import com.subtracks.data.model.Source
import com.subtracks.data.model.StringListConverter
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 35,
version = 36,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
DownloadErrorConverter::class,
PendingActionKindConverter::class,
ArtistRefsConverter::class,
StringListConverter::class,
)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -10,3 +10,8 @@ fun coverArtKey(
coverArt: String,
thumbnail: Boolean,
): String = "$sourceId:$coverArt:$thumbnail"
fun artistImageKey(
sourceId: Long,
artistId: String,
): String = "$sourceId:artist-image:$artistId"
@@ -106,10 +106,12 @@ data class DownloadArtwork(
val sourceId: Long,
val albumId: String?,
val artistId: String?,
val albumArtistId: String?,
val albumRow: String?,
val artistRow: String?,
val albumCoverArt: String?,
val artistCoverArt: String?,
val albumArtistCoverArt: String?,
) {
val hasMissingLibraryRow: Boolean
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
@@ -88,6 +88,9 @@ data class Artist(
val albumCount: Long,
val starred: Long?,
val coverArt: String? = null,
val artistImageUrl: String? = null,
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@@ -592,6 +595,16 @@ class ArtistRefsConverter {
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
}
class StringListConverter {
@ColumnTypeConverter
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
@ColumnTypeConverter
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
}
private const val LIST_SEPARATOR = "\u001F"
object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String =
buildString {
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
@@ -19,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
@@ -66,6 +68,7 @@ class DownloadRepository(
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
fun start() {
if (started) return
@@ -350,6 +353,9 @@ class DownloadRepository(
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
group.forEach { file(sourceId, it.songId).delete() }
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
if (group.any { it.status == DownloadStatus.Completed }) {
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
}
}
sweep(db.downloadDao().all())
}
@@ -372,6 +378,7 @@ class DownloadRepository(
.mapNotNull { it.engineId }
.let(engine::cancel)
db.downloadDao().deleteSource(sourceId)
db.libraryDao().clearDownloadedArtistArt(sourceId)
dir(sourceId).deleteRecursively()
}
}
@@ -413,13 +420,14 @@ class DownloadRepository(
) {
val song = db.libraryDao().songOnce(sourceId, songId)
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
val coverArt =
(
listOfNotNull(
album?.coverArt,
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
listOfNotNull(album?.coverArt) +
db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct()
coverArt.forEach { cover ->
storeArtwork(sourceId, cover, thumbnail = false)
@@ -427,6 +435,37 @@ class DownloadRepository(
}
}
private suspend fun storeArtistImage(
sourceId: Long,
artist: Artist,
) {
val imageUrl = artist.artistImageUrl
if (!imageUrl.isNullOrBlank()) {
val cacheKey = artistImageKey(sourceId, artist.id)
if (artworkStore.file(sourceId, cacheKey).exists()) return
val attemptKey = "$sourceId:${artist.id}:$imageUrl"
if (attemptKey !in failedArtistImages) {
val bytes =
try {
artworkFetcher.fetch(imageUrl)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
if (bytes != null && bytes.isNotEmpty()) {
artworkStore.write(sourceId, cacheKey, bytes)
return
}
failedArtistImages += attemptKey
}
}
artist.coverArt?.let {
storeArtwork(sourceId, it, thumbnail = false)
storeArtwork(sourceId, it, thumbnail = true)
}
}
private suspend fun fetchArtistBio(
sourceId: Long,
artistId: String,
@@ -491,11 +530,15 @@ class DownloadRepository(
.mapValues { (sourceId, rows) ->
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
val covers =
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
playlistCovers[sourceId].orEmpty()
covers
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
.toSet()
rows.flatMap { row ->
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
} + playlistCovers[sourceId].orEmpty()
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
(
covers.flatMap { cover ->
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
} + artistImages.map { artistImageKey(sourceId, it) }
).toSet()
}
}
@@ -545,6 +588,7 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId)
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
return true
}
@@ -625,6 +669,11 @@ class DownloadRepository(
},
)
if (updated != row) db.downloadDao().upsert(updated)
if (updated.status != row.status &&
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
) {
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
}
if (updated.status != row.status) {
when (updated.status) {
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
@@ -642,9 +691,22 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
if (row.status == DownloadStatus.Completed) {
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
}
Log.w("download", "failed ${row.songId}: $error")
}
private suspend fun refreshDownloadedArtistArt(
sourceId: Long,
songIds: Collection<String>,
) {
songIds.chunked(500).forEach { songs ->
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
}
}
private fun ensurePolling() {
if (pollJob?.isActive == true) return
pollJob =
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
@@ -69,6 +70,9 @@ class SourceRepository(
private val _offline = MutableStateFlow(false)
val offline: StateFlow<Boolean> = _offline
private val _activeSource = MutableStateFlow<Long?>(null)
val activeSource: StateFlow<Long?> = _activeSource
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
fun setOfflineMode(enabled: Boolean) {
@@ -109,6 +113,7 @@ class SourceRepository(
Log.i("net", "active source -> ${config?.name ?: "none"}")
}
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
_activeSource.value = config?.id
_quality.value = quality
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
scope.launch {
@@ -143,6 +148,18 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun artistImage(
artistId: String,
url: String?,
): CoverArtRef? {
val current = active ?: return null
if (url.isNullOrBlank()) return null
val cacheKey = artistImageKey(current.id, artistId)
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
if (_offline.value) return null
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun networkCoverArt(
sourceId: Long,
coverArt: String,
@@ -32,6 +32,7 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null },
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
)
@@ -438,7 +438,7 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
private val albumColumns =
listOf(
@@ -515,7 +515,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
private fun Album.values() =
listOf<Any?>(
@@ -375,7 +375,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -418,7 +426,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -514,7 +530,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -538,7 +562,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -562,7 +594,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -588,7 +628,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -0,0 +1,310 @@
package com.subtracks.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.SourceRepository
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
data class ArtistArt(
val image: CoverArtRef? = null,
val thumbnail: CoverArtRef? = null,
val covers: List<CoverArtRef> = emptyList(),
val fallback: ArtistArt? = null,
) {
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
}
fun SourceRepository.artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt =
resolveArtistArt(
artist = artist,
coverArt = this::coverArt,
artistImage = this::artistImage,
offline = offline.value,
forIcon = forIcon,
)
internal fun resolveArtistArt(
artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistImage: (String, String?) -> CoverArtRef?,
offline: Boolean,
forIcon: Boolean,
): ArtistArt {
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
val grid =
ArtistArt(
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
)
val own =
artist.coverArt?.let { coverId ->
coverArt(coverId, forIcon)?.let { image ->
ArtistArt(
image = image,
thumbnail = if (forIcon) image else coverArt(coverId, true),
fallback = grid,
)
}
}
return artist.artistImageUrl
?.let { artistImage(artist.id, it) }
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
?: own
?: grid
}
@Composable
fun ArtistIcon(
artist: Artist,
art: ArtistArt,
modifier: Modifier = Modifier,
) {
var shown by remember(artist.id, art) { mutableStateOf(art) }
val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
}
when {
shown.image != null -> {
CoverArt(
ref = shown.image,
name = artist.name,
modifier = modifier,
onError = showFallback,
)
}
shown.covers.size >= 2 -> {
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
}
else -> {
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
}
}
}
@Composable
fun ArtistHeroArt(
art: ArtistArt,
name: String,
modifier: Modifier = Modifier,
) {
var shown by remember(art) { mutableStateOf(art) }
val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
}
when {
shown.image != null -> {
CoverArt(
ref = shown.image,
name = name,
thumbnailRef = shown.thumbnail,
showPlaceholder = false,
modifier = modifier,
onError = showFallback,
)
}
shown.covers.isNotEmpty() -> {
ArtistArtGrid(
covers = shown.covers,
name = name,
singleThumbnail = shown.thumbnail,
modifier = modifier,
)
}
else -> {
CoverArt(ref = null, name = name, modifier = modifier)
}
}
}
@Composable
fun ArtistArtGrid(
covers: List<CoverArtRef>,
name: String,
modifier: Modifier = Modifier,
singleThumbnail: CoverArtRef? = null,
) {
when {
covers.size >= 4 -> {
Column(modifier) {
Row(Modifier.weight(1f).fillMaxWidth()) {
covers.take(2).forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
Row(Modifier.weight(1f).fillMaxWidth()) {
covers.drop(2).take(2).forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
}
covers.size == 3 -> {
Box(modifier) {
covers.forEachIndexed { index, ref ->
CoverArt(
ref = ref,
name = name,
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
)
}
}
}
covers.size == 2 -> {
Row(modifier) {
covers.forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
covers.size == 1 -> {
CoverArt(
ref = covers.first(),
name = name,
thumbnailRef = singleThumbnail,
showPlaceholder = false,
modifier = modifier,
)
}
}
}
private class YRegionShape(
private val region: Int,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val center = Offset(size.width / 2f, size.height / 2f)
val left = rayPoint(size, 150.0)
val right = rayPoint(size, 30.0)
val top = Offset(size.width / 2f, 0f)
val path = Path()
when (region) {
0 -> {
path.moveTo(center.x, center.y)
path.lineTo(right.x, right.y)
addClockwise(path, size, right, left)
}
1 -> {
path.moveTo(center.x, center.y)
path.lineTo(left.x, left.y)
addClockwise(path, size, left, top)
}
else -> {
path.moveTo(center.x, center.y)
path.lineTo(top.x, top.y)
addClockwise(path, size, top, right)
}
}
path.lineTo(center.x, center.y)
path.close()
return Outline.Generic(path)
}
}
private fun rayPoint(
size: Size,
angleDegrees: Double,
): Offset {
val cx = size.width / 2.0
val cy = size.height / 2.0
val radians = angleDegrees * PI / 180.0
val dx = cos(radians)
val dy = sin(radians)
val tx =
when {
dx > 0 -> (size.width - cx) / dx
dx < 0 -> -cx / dx
else -> Double.POSITIVE_INFINITY
}
val ty =
when {
dy > 0 -> (size.height - cy) / dy
dy < 0 -> -cy / dy
else -> Double.POSITIVE_INFINITY
}
val t = min(tx, ty)
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
}
private fun addClockwise(
path: Path,
size: Size,
from: Offset,
to: Offset,
) {
val w = size.width
val h = size.height
val corners =
listOf(
Offset(0f, 0f),
Offset(w, 0f),
Offset(w, h),
Offset(0f, h),
)
val fromParam = perimeterParam(from, size)
val toParam = perimeterParam(to, size)
if (fromParam <= toParam) {
corners
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
.forEach { path.lineTo(it.x, it.y) }
} else {
corners
.filter { perimeterParam(it, size) > fromParam }
.forEach { path.lineTo(it.x, it.y) }
corners
.filter { perimeterParam(it, size) < toParam }
.forEach { path.lineTo(it.x, it.y) }
}
path.lineTo(to.x, to.y)
}
private fun perimeterParam(
point: Offset,
size: Size,
): Float {
val w = size.width
val h = size.height
return when {
point.y <= 0f -> point.x
point.x >= w -> w + point.y
point.y >= h -> w + h + (w - point.x)
else -> 2f * w + h + (h - point.y)
}
}
@@ -85,12 +85,15 @@ fun CoverArt(
square: Boolean = true,
elevation: Dp = 0.dp,
colorFilter: ColorFilter? = null,
onError: (() -> Unit)? = null,
) {
val context = LocalPlatformContext.current
val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
val letterScheme =
if (showPlaceholder || (failed && !thumbnailLoaded)) {
@@ -115,6 +118,10 @@ fun CoverArt(
thumbnailLoaded = thumbnailLoaded,
onThumbnailLoaded = { thumbnailLoaded = true },
onThumbnailRatio = { thumbnailRatio = it },
onThumbnailError = {
thumbnailFailed = true
if (failed) onError?.invoke()
},
contentScale = ContentScale.Crop,
colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer,
@@ -131,7 +138,10 @@ fun CoverArt(
.ratioOrNull()
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
},
onError = { failed = true },
onError = {
failed = true
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
},
modifier = Modifier.fillMaxSize(),
)
}
@@ -160,7 +170,10 @@ fun CoverArt(
)
}
},
onError = { failed = true },
onError = {
failed = true
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
},
contentScale = ContentScale.Fit,
filterQuality = FilterQuality.High,
)
@@ -179,6 +192,10 @@ fun CoverArt(
thumbnailLoaded = thumbnailLoaded,
onThumbnailLoaded = { thumbnailLoaded = true },
onThumbnailRatio = { thumbnailRatio = it },
onThumbnailError = {
thumbnailFailed = true
if (failed) onError?.invoke()
},
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer,
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
thumbnailLoaded: Boolean,
onThumbnailLoaded: () -> Unit,
onThumbnailRatio: (Float) -> Unit,
onThumbnailError: () -> Unit,
contentScale: ContentScale,
colorFilter: ColorFilter?,
placeholderContentColor: Color?,
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
}
},
onError = { onThumbnailError() },
modifier = Modifier.fillMaxSize(),
)
}
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
@@ -108,6 +110,7 @@ fun HomeListRoute(
genres = genres,
decades = decades,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = onAlbumClick,
@@ -133,6 +136,7 @@ fun HomeListScreen(
genres: List<String> = emptyList(),
decades: List<Long> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null,
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
@@ -191,7 +195,7 @@ fun HomeListScreen(
OfflineListKind.Artists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
@@ -245,7 +249,7 @@ fun HomeListScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
@Composable
private fun ArtistsList(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
info: (Artist) -> String?,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
@@ -460,12 +464,13 @@ private fun ArtistsList(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val art = artistArt(artist)
ArtistListRow(
artist = artist,
coverArt = coverArt,
art = art,
info = info(artist),
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -475,7 +480,7 @@ private fun ArtistsList(
@Composable
internal fun ArtistListRow(
artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?,
art: ArtistArt,
info: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CoverArt(
ref = coverArt(artist.coverArt, true),
name = artist.name,
ArtistIcon(
artist = artist,
art = art,
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
)
Spacer(Modifier.width(12.dp))
@@ -16,6 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -156,6 +158,11 @@ class HomeListViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) {
val genre = request.genre
when {
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
@@ -137,6 +139,7 @@ fun HomeRoute(
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
@@ -152,6 +155,7 @@ fun HomeRoute(
HomeScreen(
feed = feed,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
@@ -173,6 +177,7 @@ fun HomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
@@ -215,6 +220,7 @@ fun HomeScreen(
HomeSection.RecentlyPlayedArtists,
feed.recentlyPlayedArtists,
coverArt,
artistArt,
onArtistClick,
onLongClick,
onMore,
@@ -249,6 +255,7 @@ fun HomeScreen(
HomeSection.MostPlayedArtists,
feed.mostPlayedArtists,
coverArt,
artistArt,
onArtistClick,
onLongClick,
onMore,
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
) {
homeTitle()
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList(
key = "downloaded-songs",
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
private fun LazyListScope.downloadedArtistRow(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit,
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val ref = coverArt(artist.coverArt, true)
val art = artistArt(artist)
ArtistTile(
artist = artist,
ref = ref,
art = art,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
section: HomeSection,
artists: List<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit,
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(artists, key = { it.id }) { artist ->
val ref = coverArt(artist.coverArt, true)
val art = artistArt(artist)
ArtistTile(
artist = artist,
ref = ref,
art = art,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -779,7 +788,7 @@ private fun AlbumTile(
@Composable
private fun ArtistTile(
artist: Artist,
ref: CoverArtRef?,
art: ArtistArt,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
@@ -787,9 +796,9 @@ private fun ArtistTile(
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CoverArt(
ref = ref,
name = artist.name,
ArtistIcon(
artist = artist,
art = art,
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
)
Spacer(Modifier.height(6.dp))
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -127,6 +129,11 @@ class HomeViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun playStarred(song: Song) {
viewModelScope.playStarredList(libraryRepository, playbackController, song)
}
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -63,9 +64,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
@@ -82,7 +87,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -95,6 +102,8 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistHeroArt
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
@@ -121,7 +130,11 @@ import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp
@@ -134,8 +147,10 @@ private val SHUFFLE_RESERVE = 86.dp
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
private val BIO_ACTION_RESERVE = 104.dp
private val BIO_ACTION_HEIGHT = 48.dp
private val BIO_ACTION_OFFSET = 8.dp
private val BIO_ACTION_OFFSET = 16.dp
private val BIO_ACTION_END_OFFSET = 8.dp
private val BIO_TOP_GAP = 8.dp
private val BIO_BOTTOM_GAP = 8.dp
internal fun estimateScrollPx(
index: Int,
@@ -193,6 +208,7 @@ fun ArtistDetailRoute(
LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -232,6 +248,7 @@ fun ArtistDetailRoute(
bioExpanded = bioExpanded,
art = art,
artThumbnail = artThumbnail,
heroArt = heroArt,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt,
onBack = onBack,
@@ -271,6 +288,7 @@ fun ArtistDetailScreen(
bioExpanded: Boolean = false,
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
heroArt: ArtistArt? = null,
artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
@@ -388,11 +406,9 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() },
) {
Box(Modifier.fillMaxWidth()) {
CoverArt(
ref = art,
ArtistHeroArt(
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameScrimAlpha > 0f) {
@@ -470,7 +486,7 @@ fun ArtistDetailScreen(
onCollapse = onCollapseBio,
modifier =
Modifier.padding(
bottom = if (hasBio && !bioCollapsed) 8.dp else 0.dp,
bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
),
)
}
@@ -554,8 +570,7 @@ fun ArtistDetailScreen(
}
}
// The expanded layout narrows only the lines that overlap the action buttons; later lines use the
// full width.
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
@Composable
private fun ArtistBio(
text: String,
@@ -587,10 +602,28 @@ private fun ArtistBio(
.toInt()
.coerceAtLeast(1)
val bioLines =
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) {
remember(
text,
fullWidth,
reserve,
reserveLines,
collapsed,
expanded,
moreText,
lessText,
) {
when {
collapsed -> {
splitBioCollapsed(measurer, text, style, fullWidth, reserve, reserveLines, moreText, maxLines = 3)
splitBioCollapsed(
measurer,
text,
style,
fullWidth,
reserve,
reserveLines,
moreText,
maxLines = 3,
)
}
expanded -> {
@@ -626,7 +659,11 @@ private fun ArtistBio(
)
}
}
Row(Modifier.align(Alignment.TopEnd).offset(y = -BIO_ACTION_OFFSET)) {
Row(
Modifier
.align(Alignment.TopEnd)
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
) {
ArtistActions(artist, starred, onToggleStar, onMore)
}
}
@@ -13,9 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -136,30 +137,27 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
val heroArt: StateFlow<ArtistArt?> = _heroArt
init {
viewModelScope.launch {
artist
.filterNotNull()
.collectLatest { loaded ->
val (full, thumbnail) = resolveArt(loaded)
_art.value = full
_artThumbnail.value = thumbnail
combine(
artist.filterNotNull(),
sourceRepository.offline,
sourceRepository.activeSource,
) { loaded, _, activeSource -> loaded to activeSource }
.collectLatest { (loaded, activeSource) ->
if (activeSource == null) return@collectLatest
val hero = sourceRepository.artistArt(loaded, forIcon = false)
val icon = sourceRepository.artistArt(loaded, forIcon = true)
_art.value = hero.image ?: hero.covers.firstOrNull()
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
_heroArt.value = hero
}
}
}
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
if (artist.coverArt == null) return null to null
repeat(3) { attempt ->
val full = sourceRepository.coverArt(artist.coverArt)
if (full != null) {
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
}
if (attempt < 2) delay(300)
}
return null to null
}
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -29,7 +29,8 @@ import com.subtracks.R
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
@@ -55,6 +56,7 @@ fun ArtistsContent(
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
) {
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -82,7 +84,8 @@ fun ArtistsContent(
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index]
if (artist != null) {
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
val art = artistArt(artist)
PrefetchArtworkSeeds(art.primary)
ListItem(
headlineContent = {
Text(
@@ -104,9 +107,9 @@ fun ArtistsContent(
)
},
leadingContent = {
CoverArt(
ref = coverArt(artist.coverArt, true),
name = artist.name,
ArtistIcon(
artist = artist,
art = art,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
@@ -117,7 +120,7 @@ fun ArtistsContent(
Modifier.combinedClickable(
onClick = { onArtistClick(artist) },
onLongClick = {
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -120,6 +120,7 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
@@ -308,6 +309,7 @@ fun LibraryRoute(
artists = viewModel.artists,
playlists = viewModel.playlists,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
@@ -396,6 +398,7 @@ fun LibraryScreen(
onSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {},
offline: Boolean = false,
onExitOffline: () -> Unit = {},
@@ -539,6 +542,7 @@ fun LibraryScreen(
onSync = onSync,
downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
artistArt = artistArt,
)
}
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -211,6 +213,11 @@ class LibraryViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
}
@@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.ItemActions
@@ -106,6 +107,7 @@ fun SearchRoute(
if (next != 0) filterMask = next
},
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = {
@@ -146,6 +148,7 @@ fun SearchScreen(
filters: Set<SearchFilter> = ALL_FILTERS,
onToggleFilter: (SearchFilter) -> Unit = {},
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null,
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
@@ -215,7 +218,7 @@ fun SearchScreen(
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
}
@@ -308,19 +311,20 @@ private fun LazyListScope.albumResults(
private fun LazyListScope.artistResults(
artists: List<Artist>,
header: String,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
) {
if (artists.isEmpty()) return
resultHeader(header)
items(artists, key = { "artist-${it.id}" }) { artist ->
val art = artistArt(artist)
ArtistListRow(
artist = artist,
coverArt = coverArt,
art = art,
info = null,
onClick = { onClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -12,6 +12,8 @@ import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import com.subtracks.ui.home.playSongInContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,6 +76,11 @@ class SearchViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) {
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
}
@@ -548,7 +548,7 @@ class LibraryDaoTest {
}
@Test
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() =
fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
@@ -615,40 +615,160 @@ class LibraryDaoTest {
}
}
dao.recomputeArtistArt(sourceId)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertEquals("new-art", artists.getValue("ar-album").coverArt)
assertEquals("track-art", artists.getValue("ar-track").coverArt)
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt)
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-album").coverArt)
assertNull(artists.getValue("ar-track").coverArt)
assertNull(artists.getValue("ar-album-ref").coverArt)
assertNull(artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-null-art").coverArt)
assertNull(artists.getValue("ar-none").coverArt)
assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt)
assertEquals(listOf("own-album"), artists.getValue("ar-own").coverArts)
assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts)
assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts)
assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts)
assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts)
assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts)
assertEquals(emptyList<String>(), artists.getValue("ar-none").coverArts)
assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts)
assertEquals(
"album-ref-art",
listOf("album-ref-art"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
.page()
.first { it.id == "ar-album-ref" }
.coverArt,
.coverArts,
)
}
@Test
fun artistArtFallbackFindsCreditsThroughArtistIndexes() =
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
runTest {
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20")
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
val sourceId = source()
val dao = db.libraryDao()
dao.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"),
),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
dao.upsertSongs(
listOf(
song(sourceId, "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(),
)
}
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@@ -153,6 +154,111 @@ class MigrationsTest {
}
}
@Test
fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
)
MIGRATION_35_36.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'"))
assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
migrated.execSQL(
"UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " +
"downloadedCoverArts = 'c' WHERE id = 'ar1'",
)
assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'"))
assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
}
}
@Test
fun theArtistArtMigrationBackfillsExistingLibraries() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 3, NULL)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " +
"year, starred, songCount) VALUES " +
"(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " +
"(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " +
"(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " +
"(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " +
"(1, 's3', 'al3', 'ar1', 'Three')",
)
migrated.execSQL(
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " +
"(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)",
)
MIGRATION_35_36.migrate(migrated)
assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
}
}
@Test
fun theArtistSearchTriggersIgnoreArtOnlyUpdates() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
MIGRATION_35_36.migrate(migrated)
assertTrue(artistSearchTriggersOptimized(migrated))
assertTrue(
triggerSql(migrated, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
.contains("UPDATE OF `name`, `sortName`"),
)
assertTrue(
triggerSql(migrated, "room_fts_content_sync_artist_search_AFTER_UPDATE")
.contains("UPDATE OF `name`, `sortName`"),
)
}
}
@Test
fun theFreshDatabaseCallbackOptimizesTheArtistSearchTriggers() =
runTest {
driver.open(":memory:").use { connection ->
applySchema(connection, 36)
connection.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
connection.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
)
ArtistSearchTriggerOptimization.onOpen(connection)
assertTrue(artistSearchTriggersOptimized(connection))
val before = long(connection, "SELECT total_changes()")
connection.execSQL("UPDATE artists SET coverArts = 'a' WHERE id = 'ar1'")
assertEquals(before + 1, long(connection, "SELECT total_changes()"))
}
}
@Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest {
@@ -395,6 +501,16 @@ class MigrationsTest {
statement.getLong(0)
}
private fun triggerSql(
connection: SQLiteConnection,
trigger: String,
): String =
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").use { statement ->
statement.bindText(1, trigger)
check(statement.step()) { "No trigger $trigger" }
statement.getText(0)
}
private fun indexColumn(
connection: SQLiteConnection,
index: String,
@@ -428,6 +544,10 @@ class MigrationsTest {
for (i in 0 until indices.length()) {
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
}
val triggers = entity.optJSONArray("contentSyncTriggers") ?: continue
for (i in 0 until triggers.length()) {
connection.execSQL(triggers.getString(i))
}
}
}
@@ -39,7 +39,12 @@ class QueryTimingMeasurementTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(driver)
.addCallback(ArtistSearchTriggerOptimization)
.build()
}
@After
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false
private val failingArtUrls = CopyOnWriteArrayList<String>()
private val fetcher =
ArtworkFetcher { url ->
requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom")
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
byteArrayOf(1, 2, 3)
}
@@ -344,7 +346,10 @@ class DownloadRepositoryTest {
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
runBlocking { repository.download(1, "s1") }
runBlocking {
repository.download(1, "s1")
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART).copy(downloadedCoverArts = listOf(ALBUM_ART))))
}
val id = engine.requests.single().first
runBlocking { repository.removeSource(1) }
@@ -352,6 +357,13 @@ class DownloadRepositoryTest {
assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists())
assertTrue(allRows().isEmpty())
assertTrue(
db
.libraryDao()
.artistOnce(1, "ar1")!!
.downloadedCoverArts
.isEmpty(),
)
}
@Test
@@ -381,6 +393,95 @@ class DownloadRepositoryTest {
}
}
@Test
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
runBlocking { repository.download(1, "s1") }
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
val key = artistImageKey(1, "ar1")
assertNotNull(artwork.uri(1, key))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, key))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, key))
}
@Test
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(
listOf(
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
),
)
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
}
runBlocking { repository.download(1, "s1") }
val trackKey = artistImageKey(1, "ar-track")
val albumKey = artistImageKey(1, "ar-album")
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, trackKey))
assertNull(artwork.uri(1, albumKey))
}
@Test
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
failingArtUrls += "https://img.example/ar1.jpg"
runBlocking { repository.download(1, "s1") }
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
}
@Test
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.complete(id, total = 100)
writeFile(1, "s1", "audio")
runBlocking { repository.reconcile() }
await { row(1, "s1")?.status == DownloadStatus.Completed }
assertEquals(
listOf(ALBUM_ART),
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
)
runBlocking { repository.remove(1, "s1") }
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
}
@Test
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
runTest {
@@ -1282,8 +1383,19 @@ class DownloadRepositoryTest {
songCount = 2,
)
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
private fun artist(
coverArt: String?,
artistImageUrl: String? = null,
) = Artist(
sourceId = 1,
id = "ar1",
name = "Artist",
albumCount = 1,
starred = null,
coverArt = coverArt,
artistImageUrl = artistImageUrl,
songCount = 2,
)
private fun seedLibrary(address: String = "http://localhost:4533/") {
runBlocking {
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -283,6 +285,35 @@ class SourceRepositoryTest {
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
}
@Test
fun activeSourceBecomesReadyAfterThePreferencesLoad() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val active = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null } }
assertEquals(repository.activeSourceId().first(), active)
}
@Test
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val sourceId = awaitActiveSourceId()
val url = "https://img.example/ar1.jpg"
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
assertEquals(url, online?.url)
val key = artistImageKey(sourceId, "ar1")
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
repository.setOfflineMode(true)
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"
artistImageUrl="https://img.example/radiohead.jpg"/>
<artist id="ar2" name="Portishead" albumCount="3"/>
</artists>
</subsonic-response>
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
assertEquals(7L, artists[0].sourceId)
assertEquals(9L, artists[0].albumCount)
assertEquals(1577934245L, artists[0].starred)
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
assertEquals(null, artists[1].artistImageUrl)
assertEquals(null, artists[1].starred)
}
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
@@ -321,6 +322,38 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_bio_expanded.png")
}
@Test
fun artistDetailArtGrid() {
composeRule.setContent {
SubtracksTheme {
ArtistDetailScreen(
artist = Fixtures.artists.first(),
albums =
ArtistAlbums(
albumCredits = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
),
art = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
artThumbnail = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
heroArt =
ArtistArt(
covers =
Fixtures.albums
.take(3)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") },
),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onAlbumClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Kid A")
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_art_grid.png")
}
private fun renderArtistDetail(bioExpanded: Boolean) {
composeRule.setContent {
SubtracksTheme {
@@ -584,6 +617,16 @@ class AppScreenshotTest {
artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
artistArt = { artist ->
ArtistArt(
covers =
Fixtures.albums
.filter { it.artistId == artist.id }
.take(4)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") },
)
},
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
@@ -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,
)
}