metadata: theme each screenshot and show the miniplayer

Use a different album for the now-playing and album screens (Chillhop Winter,
My latin way) so the store shots show distinct colour schemes, tint the home
screen with the playing album so the logo pops, and render the miniplayer on
the albums, album, artists and search screens. Search now runs "chill".

The generator stages the sync into an on-disk database and caches cover bytes
under the temp dir, and only fetches full-resolution art for the covers shown
large; grid art uses the thumbnail endpoint.
This commit is contained in:
austinried
2026-10-04 00:30:08 +00:00
parent 0410ed026c
commit 0e96b5872c
7 changed files with 163 additions and 70 deletions
@@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Color import android.graphics.Color
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -12,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
@@ -50,6 +52,7 @@ import com.subtracks.ui.library.ArtistDetailScreen
import com.subtracks.ui.library.LibraryScreen import com.subtracks.ui.library.LibraryScreen
import com.subtracks.ui.library.LibraryTab import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.library.sortOptionsFor import com.subtracks.ui.library.sortOptionsFor
import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingScreen import com.subtracks.ui.playback.NowPlayingScreen
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
@@ -67,6 +70,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import java.io.File
/** /**
* Records the store screenshots from a real sync of the public Navidrome demo library. It is not run * Records the store screenshots from a real sync of the public Navidrome demo library. It is not run
@@ -123,9 +127,9 @@ class DemoScreenshotTest {
NowPlayingScreen( NowPlayingScreen(
state = demo.playbackState, state = demo.playbackState,
positionMs = demo.positionMs, positionMs = demo.positionMs,
title = demo.album.name, title = demo.nowPlayingAlbum.name,
coverArt = demo.coverArt(demo.album.coverArt, false), coverArt = demo.coverArt(demo.nowPlayingAlbum.coverArt, false),
artwork = demo.artwork(demo.album.coverArt), artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
onBack = {}, onBack = {},
onQueue = {}, onQueue = {},
onPlayPause = {}, onPlayPause = {},
@@ -137,10 +141,14 @@ class DemoScreenshotTest {
} }
Screen.Albums -> { Screen.Albums -> {
Box(Modifier.fillMaxSize()) {
AlbumsScreen(demo.albums, demo.coverArt, demo.artwork(demo.albums.first().coverArt)) AlbumsScreen(demo.albums, demo.coverArt, demo.artwork(demo.albums.first().coverArt))
demo.MiniPlayerOverlay(Modifier.align(Alignment.BottomCenter))
}
} }
Screen.AlbumDetail -> { Screen.AlbumDetail -> {
Box(Modifier.fillMaxSize()) {
AlbumDetailScreen( AlbumDetailScreen(
album = demo.album, album = demo.album,
songs = demo.albumSongs, songs = demo.albumSongs,
@@ -148,9 +156,11 @@ class DemoScreenshotTest {
artwork = demo.artwork(demo.album.coverArt), artwork = demo.artwork(demo.album.coverArt),
onBack = {}, onBack = {},
onSongClick = {}, onSongClick = {},
playingSongId = demo.song.id, playingSongId = demo.nowPlayingSong.id,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
demo.MiniPlayerOverlay(Modifier.align(Alignment.BottomCenter))
}
} }
Screen.ArtistDetail -> { Screen.ArtistDetail -> {
@@ -168,6 +178,7 @@ class DemoScreenshotTest {
} }
Screen.Artists -> { Screen.Artists -> {
Box(Modifier.fillMaxSize()) {
LibraryScreen( LibraryScreen(
selectedTab = LibraryTab.Artists, selectedTab = LibraryTab.Artists,
onTabSelected = {}, onTabSelected = {},
@@ -183,9 +194,12 @@ class DemoScreenshotTest {
artwork = demo.artwork(demo.artists.first().coverArt), artwork = demo.artwork(demo.artists.first().coverArt),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
demo.MiniPlayerOverlay(Modifier.align(Alignment.BottomCenter))
}
} }
Screen.Search -> { Screen.Search -> {
Box(Modifier.fillMaxSize()) {
LibraryScreen( LibraryScreen(
selectedTab = LibraryTab.Albums, selectedTab = LibraryTab.Albums,
onTabSelected = {}, onTabSelected = {},
@@ -206,6 +220,8 @@ class DemoScreenshotTest {
artwork = demo.artwork(demo.searchAlbums.first().coverArt), artwork = demo.artwork(demo.searchAlbums.first().coverArt),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
demo.MiniPlayerOverlay(Modifier.align(Alignment.BottomCenter))
}
} }
} }
} }
@@ -259,13 +275,28 @@ class DemoScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/demo/$file") composeRule.onRoot().captureRoboImage("src/test/screenshots/demo/$file")
} }
@Composable
private fun DemoData.MiniPlayerOverlay(modifier: Modifier = Modifier) {
MiniPlayer(
state = playbackState,
positionMs = positionMs,
coverArt = coverArt(nowPlayingAlbum.coverArt, true),
artwork = artwork(nowPlayingAlbum.coverArt),
onExpand = {},
onPlayPause = {},
onNext = {},
modifier = modifier,
)
}
private enum class Screen { Home, NowPlaying, Albums, AlbumDetail, ArtistDetail, Artists, Search } private enum class Screen { Home, NowPlaying, Albums, AlbumDetail, ArtistDetail, Artists, Search }
private class DemoData( private class DemoData(
val albums: List<Album>, val albums: List<Album>,
val artists: List<Artist>, val artists: List<Artist>,
val feed: HomeFeed, val feed: HomeFeed,
val song: Song, val nowPlayingSong: Song,
val nowPlayingAlbum: Album,
val album: Album, val album: Album,
val albumSongs: List<Song>, val albumSongs: List<Song>,
val homePlayingAlbum: Album, val homePlayingAlbum: Album,
@@ -281,19 +312,19 @@ class DemoScreenshotTest {
PlaybackState( PlaybackState(
item = item =
QueueItem( QueueItem(
id = song.id, id = nowPlayingSong.id,
title = song.title, title = nowPlayingSong.title,
artist = song.artist, artist = nowPlayingSong.artist,
album = song.album, album = nowPlayingSong.album,
coverArtId = album.coverArt, coverArtId = nowPlayingAlbum.coverArt,
), ),
isPlaying = true, isPlaying = true,
durationMs = (song.duration ?: 0) * 1000, durationMs = (nowPlayingSong.duration ?: 0) * 1000,
hasNext = true, hasNext = true,
hasPrevious = false, hasPrevious = false,
) )
val positionMs: Long = (song.duration ?: 0) * 1000 * 35 / 100 val positionMs: Long = (nowPlayingSong.duration ?: 0) * 1000 * 35 / 100
fun artwork(coverArtId: String?): ArtworkColors { fun artwork(coverArtId: String?): ArtworkColors {
val bitmap = coverArtId?.let(bitmaps::get) ?: return artworkColorsFromSeeds(FALLBACK_SEED, null) val bitmap = coverArtId?.let(bitmaps::get) ?: return artworkColorsFromSeeds(FALLBACK_SEED, null)
@@ -326,9 +357,10 @@ class DemoScreenshotTest {
) )
val source: SubsonicSource = TestServers.source(server) val source: SubsonicSource = TestServers.source(server)
context.deleteDatabase(DEMO_DB_NAME)
val db = val db =
Room Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) .databaseBuilder<SubtracksDatabase>(context, DEMO_DB_NAME)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
.build() .build()
db.sourcesDao().upsertSource( db.sourcesDao().upsertSource(
@@ -341,9 +373,11 @@ class DemoScreenshotTest {
val artists = dao.artistIds(1).mapNotNull { dao.artist(1, it).first() } val artists = dao.artistIds(1).mapNotNull { dao.artist(1, it).first() }
val playlists = dao.playlistIds(1).mapNotNull { dao.playlist(1, it).first() } val playlists = dao.playlistIds(1).mapNotNull { dao.playlist(1, it).first() }
val album = albums.firstOrNull { it.name == "Future, Sex, Computers" } ?: albums.first() val nowPlayingAlbum = albums.firstOrNull { it.name == "Chillhop Essentials - Winter 2016" } ?: albums.first()
val nowPlayingSongs = dao.songsByAlbum(1, nowPlayingAlbum.id).first()
val nowPlayingSong = nowPlayingSongs.getOrNull(1) ?: nowPlayingSongs.first()
val album = albums.firstOrNull { it.name == "My latin way" } ?: albums.first()
val albumSongs = dao.songsByAlbum(1, album.id).first() val albumSongs = dao.songsByAlbum(1, album.id).first()
val song = albumSongs.getOrNull(1) ?: albumSongs.first()
val homePlayingAlbum = albums.firstOrNull { it.name == "Shaking The Habitual" } ?: albums.last() val homePlayingAlbum = albums.firstOrNull { it.name == "Shaking The Habitual" } ?: albums.last()
val homePlayingSong = dao.songsByAlbum(1, homePlayingAlbum.id).first().first() val homePlayingSong = dao.songsByAlbum(1, homePlayingAlbum.id).first().first()
val artist = val artist =
@@ -376,15 +410,27 @@ class DemoScreenshotTest {
.filterNotNull() .filterNotNull()
.distinct() .distinct()
val fullSizeIds =
buildSet {
addAll(listOfNotNull(album.coverArt, nowPlayingAlbum.coverArt, artist.coverArt))
artistAlbums.mapNotNullTo(this) { it.coverArt }
}
val bitmaps = mutableMapOf<String, Bitmap>() val bitmaps = mutableMapOf<String, Bitmap>()
val builder = FakeImageLoaderEngine.Builder() val builder = FakeImageLoaderEngine.Builder()
val coverDir = DEMO_COVER_DIR.apply { mkdirs() }
coverArtIds.forEach { id -> coverArtIds.forEach { id ->
val bitmap = fetchBitmap(http, source, id) ?: return@forEach val thumbnail = fetchBitmap(http, source, id, thumbnail = true, coverDir) ?: return@forEach
bitmaps[id] = bitmap source.coverArtUri(id, true)?.let { builder.intercept(it.toString(), thumbnail.asImage()) }
val image = bitmap.asImage() val image =
listOf(false, true).forEach { thumbnail -> if (id in fullSizeIds) {
source.coverArtUri(id, thumbnail)?.let { builder.intercept(it.toString(), image) } (fetchBitmap(http, source, id, thumbnail = false, coverDir) ?: thumbnail).also {
bitmaps[id] = it
} }
} else {
thumbnail.also { bitmaps[id] = it }
}
source.coverArtUri(id, false)?.let { builder.intercept(it.toString(), image.asImage()) }
} }
val fallback = val fallback =
Bitmap Bitmap
@@ -406,7 +452,8 @@ class DemoScreenshotTest {
albums = albums, albums = albums,
artists = artists, artists = artists,
feed = feed, feed = feed,
song = song, nowPlayingSong = nowPlayingSong,
nowPlayingAlbum = nowPlayingAlbum,
album = album, album = album,
albumSongs = albumSongs, albumSongs = albumSongs,
homePlayingAlbum = homePlayingAlbum, homePlayingAlbum = homePlayingAlbum,
@@ -424,16 +471,62 @@ class DemoScreenshotTest {
http: OkHttpClient, http: OkHttpClient,
source: SubsonicSource, source: SubsonicSource,
id: String, id: String,
thumbnail: Boolean,
coverDir: File,
): Bitmap? = ): Bitmap? =
try { try {
val url = source.coverArtUri(id, false) ?: return null val url = source.coverArtUri(id, thumbnail) ?: return null
val bytes = http.newCall(Request.Builder().url(url).build()).execute().use { it.body.bytes() } val file = File(coverDir, "${url.hashCode().toUInt().toString(16)}.img")
if (!file.exists()) {
http.newCall(Request.Builder().url(url).build()).execute().use { response ->
response.body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
}
}
val bytes = file.readBytes()
if (thumbnail) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size) BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} else {
decodeDownsampled(bytes, MAX_ART_DIM)
}
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
private fun decodeDownsampled(
bytes: ByteArray,
maxDim: Int,
): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxDim) sample *= 2
val decoded =
BitmapFactory.decodeByteArray(
bytes,
0,
bytes.size,
BitmapFactory.Options().apply { inSampleSize = sample },
) ?: return null
val largest = maxOf(decoded.width, decoded.height)
if (largest <= maxDim) return decoded
val scale = maxDim.toFloat() / largest
val scaled =
Bitmap.createScaledBitmap(
decoded,
(decoded.width * scale).toInt().coerceAtLeast(1),
(decoded.height * scale).toInt().coerceAtLeast(1),
true,
)
if (scaled !== decoded) decoded.recycle()
return scaled
}
} }
} }
} }
private const val DEMO_SEARCH = "chill" private const val DEMO_SEARCH = "chill"
private const val MAX_ART_DIM = 1200
private const val DEMO_DB_NAME = "demo-screens.db"
private val DEMO_COVER_DIR = File(System.getProperty("java.io.tmpdir"), "subtracks-demo-covers")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

After

Width:  |  Height:  |  Size: 993 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 313 KiB