From f4ca0fd6d43ef352e11deb3fd0d45e5c574d3b11 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:41:29 +0000 Subject: [PATCH] compute and persist the artist name busy flag with the artwork seed - ArtworkSeedStore now carries the flag; artwork_seeds gains a nullable nameBusy column (DB v12, migration 11->12) so it survives cold starts with no decode - resolve() computes the flag from the same bitmap it extracts the colour seed from, so there is no second decode, and persists it alongside the seed - overlaidNameBusy reuses the seed job and reads the cached flag synchronously, so the scrim is known before the art shows; drop the fade - bottomBandBusy now uses perceptual (gamma-space) luma, edge density and a bright fraction, which catches busy art on dark backgrounds (issue #40) that mean/variance missed --- .../12.json | 845 ++++++++++++++++++ .../com/subtracks/data/db/Migrations.kt | 8 + .../subtracks/data/db/SubtracksDatabase.kt | 2 +- .../kotlin/com/subtracks/data/model/Models.kt | 1 + .../data/repo/ArtworkSeedRepository.kt | 19 +- .../ui/library/ArtistDetailScreen.kt | 7 +- .../subtracks/ui/theme/ArtworkSeedCache.kt | 68 +- .../subtracks/ui/theme/ArtworkNameBandTest.kt | 14 + 8 files changed, 916 insertions(+), 48 deletions(-) create mode 100644 app/schemas/com.subtracks.data.db.SubtracksDatabase/12.json diff --git a/app/schemas/com.subtracks.data.db.SubtracksDatabase/12.json b/app/schemas/com.subtracks.data.db.SubtracksDatabase/12.json new file mode 100644 index 00000000..afc72f51 --- /dev/null +++ b/app/schemas/com.subtracks.data.db.SubtracksDatabase/12.json @@ -0,0 +1,845 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "4c665bdfecebbac1f25dee3e7337491c", + "entities": [ + { + "tableName": "sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "subsonic_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `useTokenAuth` INTEGER NOT NULL, PRIMARY KEY(`sourceId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "useTokenAuth", + "columnName": "useTokenAuth", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId" + ] + }, + "indices": [ + { + "name": "index_subsonic_sources_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subsonic_sources_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumCount", + "columnName": "albumCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_artists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "albums", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, `name` TEXT NOT NULL, `albumArtist` TEXT, `created` INTEGER NOT NULL, `coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, `frequentRank` INTEGER, `recentRank` INTEGER, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumArtist", + "columnName": "albumArtist", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frequentRank", + "columnName": "frequentRank", + "affinity": "INTEGER" + }, + { + "fieldPath": "recentRank", + "columnName": "recentRank", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_albums_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_albums_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `disc` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `albumId`, `disc`), FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "albumId", + "disc" + ] + }, + "indices": [ + { + "name": "index_discs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + } + ], + "foreignKeys": [ + { + "table": "albums", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId", + "albumId" + ], + "referencedColumns": [ + "sourceId", + "id" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, `created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + }, + { + "fieldPath": "coverArt", + "columnName": "coverArt", + "affinity": "TEXT" + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changed", + "columnName": "changed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_playlists_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`sourceId`, `playlistId`, `position`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "playlistId", + "position" + ] + }, + "indices": [ + { + "name": "index_playlist_songs_sourceId_playlistId", + "unique": false, + "columnNames": [ + "sourceId", + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_playlistId` ON `${TABLE_NAME}` (`sourceId`, `playlistId`)" + }, + { + "name": "index_playlist_songs_sourceId_songId", + "unique": false, + "columnNames": [ + "sourceId", + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_songs_sourceId_songId` ON `${TABLE_NAME}` (`sourceId`, `songId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `albumId` TEXT, `artistId` TEXT, `title` TEXT NOT NULL, `album` TEXT, `artist` TEXT, `duration` INTEGER, `track` INTEGER, `disc` INTEGER, `starred` INTEGER, `genre` TEXT, `created` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT" + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + }, + { + "fieldPath": "track", + "columnName": "track", + "affinity": "INTEGER" + }, + { + "fieldPath": "disc", + "columnName": "disc", + "affinity": "INTEGER" + }, + { + "fieldPath": "starred", + "columnName": "starred", + "affinity": "INTEGER" + }, + { + "fieldPath": "genre", + "columnName": "genre", + "affinity": "TEXT" + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceId", + "id" + ] + }, + "indices": [ + { + "name": "index_songs_sourceId_albumId", + "unique": false, + "columnNames": [ + "sourceId", + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_albumId` ON `${TABLE_NAME}` (`sourceId`, `albumId`)" + }, + { + "name": "index_songs_sourceId_artistId", + "unique": false, + "columnNames": [ + "sourceId", + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_sourceId_artistId` ON `${TABLE_NAME}` (`sourceId`, `artistId`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `position` INTEGER NOT NULL, `sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, `refId` TEXT NOT NULL, `rangeStart` INTEGER, `rangeEnd` INTEGER, FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "refId", + "columnName": "refId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rangeStart", + "columnName": "rangeStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "rangeEnd", + "columnName": "rangeEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_queue_entries_sourceId", + "unique": false, + "columnNames": [ + "sourceId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_sourceId` ON `${TABLE_NAME}` (`sourceId`)" + }, + { + "name": "index_queue_entries_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_entries_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourceId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playback_cursor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `queuePosition` INTEGER NOT NULL, `shuffleEnabled` INTEGER NOT NULL DEFAULT 0, `repeatMode` INTEGER NOT NULL DEFAULT 0, `shuffleSeed` INTEGER NOT NULL DEFAULT 0, `positionMs` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "queuePosition", + "columnName": "queuePosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shuffleEnabled", + "columnName": "shuffleEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeatMode", + "columnName": "repeatMode", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "shuffleSeed", + "columnName": "shuffleSeed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "shuffle_order", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER NOT NULL, `flatPosition` INTEGER NOT NULL, PRIMARY KEY(`sequence`))", + "fields": [ + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flatPosition", + "columnName": "flatPosition", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sequence" + ] + } + }, + { + "tableName": "artwork_seeds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cacheKey` TEXT NOT NULL, `primary` INTEGER NOT NULL, `secondary` INTEGER, `nameBusy` INTEGER, PRIMARY KEY(`cacheKey`))", + "fields": [ + { + "fieldPath": "cacheKey", + "columnName": "cacheKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primary", + "columnName": "primary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondary", + "columnName": "secondary", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameBusy", + "columnName": "nameBusy", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "cacheKey" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c665bdfecebbac1f25dee3e7337491c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt index ebade9a9..eb6120d9 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Migrations.kt @@ -105,6 +105,13 @@ val MIGRATION_10_11 = } } +val MIGRATION_11_12 = + object : Migration(11, 12) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `artwork_seeds` ADD COLUMN `nameBusy` INTEGER") + } + } + val MIGRATIONS: Array = arrayOf( MIGRATION_1_2, @@ -117,4 +124,5 @@ val MIGRATIONS: Array = MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, + MIGRATION_11_12, ) diff --git a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt index 557b6ec6..8161808a 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/SubtracksDatabase.kt @@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource ShuffleOrder::class, ArtworkSeed::class, ], - version = 11, + version = 12, exportSchema = true, ) @ColumnTypeConverters(QueueKindConverter::class) diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 05dd2da0..9721745c 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -242,6 +242,7 @@ data class ArtworkSeed( @PrimaryKey val cacheKey: String, val primary: Int, val secondary: Int?, + val nameBusy: Boolean? = null, ) class QueueKindConverter { diff --git a/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt index f5739a2a..0c0c7f02 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/ArtworkSeedRepository.kt @@ -4,26 +4,17 @@ import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.model.ArtworkSeed interface ArtworkSeedStore { - suspend fun seed(cacheKey: String): Pair? + suspend fun seed(cacheKey: String): ArtworkSeed? - suspend fun save( - cacheKey: String, - seeds: Pair, - ) + suspend fun save(seed: ArtworkSeed) } class ArtworkSeedRepository( private val db: SubtracksDatabase, ) : ArtworkSeedStore { - override suspend fun seed(cacheKey: String): Pair? { - val row = db.artworkSeedDao().seed(cacheKey) ?: return null - return row.primary to row.secondary - } + override suspend fun seed(cacheKey: String): ArtworkSeed? = db.artworkSeedDao().seed(cacheKey) - override suspend fun save( - cacheKey: String, - seeds: Pair, - ) { - db.artworkSeedDao().upsert(ArtworkSeed(cacheKey, seeds.first, seeds.second)) + override suspend fun save(seed: ArtworkSeed) { + db.artworkSeedDao().upsert(seed) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 85b40294..8ca33e40 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -1,7 +1,5 @@ package com.subtracks.ui.library -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -73,7 +71,6 @@ private val ART_HEIGHT = 420.dp private val TITLE_INSET = 16.dp private val FADE_LEAD = 24.dp private const val THEME_TRANSITION_MS = 100 -private const val SCRIM_FADE_MS = 180 internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim" @Composable @@ -148,7 +145,6 @@ fun ArtistDetailScreen( ), ) val nameBusy = rememberOverlaidNameBusy(art ?: artThumbnail) - val nameScrimAlpha by animateFloatAsState(if (nameBusy) 1f else 0f, tween(SCRIM_FADE_MS), label = "artistNameScrim") CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(artwork) { @@ -185,14 +181,13 @@ fun ArtistDetailScreen( showPlaceholder = art == null, modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), ) - if (nameScrimAlpha > 0f) { + if (nameBusy) { Box( modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth() .height(ART_HEIGHT * 0.4f) - .graphicsLayer { alpha = nameScrimAlpha } .background( Brush.verticalGradient( listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)), diff --git a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt index ca1169e9..d7d8b930 100644 --- a/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt +++ b/app/src/main/kotlin/com/subtracks/ui/theme/ArtworkSeedCache.kt @@ -16,6 +16,7 @@ import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.toBitmap +import com.subtracks.data.model.ArtworkSeed import com.subtracks.data.model.CoverArtRef import com.subtracks.data.repo.ArtworkSeedStore import kotlinx.coroutines.CancellationException @@ -28,14 +29,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs -import kotlin.math.sqrt private const val SEED_ART_SIZE_PX = 128 private const val PREFETCH_SETTLE_MS = 250L private const val MAX_CACHED_SEEDS = 256 private const val BUSY_BAND_TOP = 0.72f -private const val BUSY_MEAN_LUMINANCE = 0.52f -private const val BUSY_STDDEV_LUMINANCE = 0.16f +private const val BUSY_BRIGHT_LUMINANCE = 0.6f +private const val BUSY_BRIGHT_FRACTION = 0.05f +private const val BUSY_EDGE_LUMINANCE = 0.2f +private const val BUSY_EDGE_FRACTION = 0.1f object ArtworkSeedCache { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -52,6 +54,8 @@ object ArtworkSeedCache { fun cached(cacheKey: String): Pair? = cache.get(cacheKey) + fun cachedBusy(cacheKey: String): Boolean? = busy.get(cacheKey) + suspend fun seeds( context: Context, ref: CoverArtRef, @@ -74,9 +78,8 @@ object ArtworkSeedCache { ref: CoverArtRef, ): Boolean { busy.get(ref.cacheKey)?.let { return it } - val result = decode(context, ref)?.let(::bottomBandBusy) ?: false - busy.put(ref.cacheKey, result) - return result + seeds(context, ref) + return busy.get(ref.cacheKey) ?: false } @VisibleForTesting @@ -90,13 +93,17 @@ object ArtworkSeedCache { context: Context, ref: CoverArtRef, ): Pair? { - ignoreFailure { store?.seed(ref.cacheKey) }?.let { - cache.put(ref.cacheKey, it) - return it + ignoreFailure { store?.seed(ref.cacheKey) }?.let { stored -> + cache.put(ref.cacheKey, stored.primary to stored.secondary) + stored.nameBusy?.let { busy.put(ref.cacheKey, it) } + if (stored.nameBusy != null) return stored.primary to stored.secondary } - val seeds = extract(context, ref) ?: return null + val bitmap = decode(context, ref) ?: return null + val seeds = seedsFrom(bitmap) ?: return null + val nameBusy = bottomBandBusy(bitmap) cache.put(ref.cacheKey, seeds) - ignoreFailure { store?.save(ref.cacheKey, seeds) } + busy.put(ref.cacheKey, nameBusy) + ignoreFailure { store?.save(ArtworkSeed(ref.cacheKey, seeds.first, seeds.second, nameBusy)) } return seeds } @@ -109,11 +116,6 @@ object ArtworkSeedCache { null } - private suspend fun extract( - context: Context, - ref: CoverArtRef, - ): Pair? = decode(context, ref)?.let(::seedsFrom) - private suspend fun decode( context: Context, ref: CoverArtRef, @@ -135,25 +137,36 @@ object ArtworkSeedCache { null } - // ponytail: whole-band mean/variance heuristic, swap for an edge-density pass if it misfires @VisibleForTesting internal fun bottomBandBusy(bitmap: Bitmap): Boolean { val firstRow = (bitmap.height * BUSY_BAND_TOP).toInt().coerceIn(0, bitmap.height - 1) - var sum = 0f - var sumSquares = 0f + val width = bitmap.width + var previousRow: FloatArray? = null + var bright = 0 + var edges = 0 var count = 0 for (y in firstRow until bitmap.height) { - for (x in 0 until bitmap.width) { - val luminance = Color(bitmap.getPixel(x, y)).luminance() - sum += luminance - sumSquares += luminance * luminance + val row = FloatArray(width) { x -> lumaAt(bitmap, x, y) } + for (x in 0 until width) { + val luminance = row[x] + if (luminance > BUSY_BRIGHT_LUMINANCE) bright++ + if (x > 0 && abs(luminance - row[x - 1]) > BUSY_EDGE_LUMINANCE) edges++ + if (previousRow != null && abs(luminance - previousRow[x]) > BUSY_EDGE_LUMINANCE) edges++ count++ } + previousRow = row } if (count == 0) return false - val mean = sum / count - val deviation = sqrt((sumSquares / count - mean * mean).coerceAtLeast(0f)) - return mean > BUSY_MEAN_LUMINANCE || deviation > BUSY_STDDEV_LUMINANCE + return bright.toFloat() / count > BUSY_BRIGHT_FRACTION || edges.toFloat() / count > BUSY_EDGE_FRACTION + } + + private fun lumaAt( + bitmap: Bitmap, + x: Int, + y: Int, + ): Float { + val color = Color(bitmap.getPixel(x, y)) + return 0.2126f * color.red + 0.7152f * color.green + 0.0722f * color.blue } private fun seedsFrom(bitmap: Bitmap): Pair? { @@ -206,7 +219,8 @@ fun PrefetchArtworkSeeds(ref: CoverArtRef?) { @Composable fun rememberOverlaidNameBusy(ref: CoverArtRef?): Boolean { val context = LocalPlatformContext.current - return produceState(false, ref?.cacheKey) { + val key = ref?.cacheKey + return produceState(key?.let(ArtworkSeedCache::cachedBusy) ?: false, key) { value = ref?.let { ArtworkSeedCache.overlaidNameBusy(context, it) } ?: false }.value } diff --git a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt index d75fd010..696790da 100644 --- a/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/theme/ArtworkNameBandTest.kt @@ -26,6 +26,11 @@ class ArtworkNameBandTest { assertFalse(ArtworkSeedCache.bottomBandBusy(bitmap { _, _ -> Color.rgb(24, 20, 34) })) } + @Test + fun smoothDarkGradientNeedsNoScrim() { + assertFalse(ArtworkSeedCache.bottomBandBusy(bitmap { x, _ -> Color.rgb(x * 2, x * 2, x * 2) })) + } + @Test fun brightBandNeedsAScrim() { assertTrue(ArtworkSeedCache.bottomBandBusy(bitmap { _, _ -> Color.rgb(235, 230, 220) })) @@ -35,4 +40,13 @@ class ArtworkNameBandTest { fun noisyBandNeedsAScrim() { assertTrue(ArtworkSeedCache.bottomBandBusy(bitmap { x, y -> if ((x + y) % 2 == 0) Color.WHITE else Color.BLACK })) } + + @Test + fun darkBandWithBrightStrokesNeedsAScrim() { + assertTrue( + ArtworkSeedCache.bottomBandBusy( + bitmap { x, y -> if (x % 9 == 0 || y % 9 == 0) Color.rgb(210, 40, 40) else Color.BLACK }, + ), + ) + } }