10 Commits
Author SHA1 Message Date
austinried 916da72339 settings: say a peakless preamp cannot boost
The preamp prompt promised extra level unconditionally, but a track whose gain has no peak is capped at unity. Say so in the supporting text so the setting does not overpromise for Opus/R128 libraries.
2026-10-18 13:40:41 +00:00
austinried 87dbabb496 playback: only attenuate a gain with no peak
A peak is the only headroom check the gain code has. When the server sends a gain without one (Navidrome does this for Opus R128 gains, which carry no peak tags by design), a positive gain was applied unchecked and the 16-bit sink saturated.

Cap a peakless gain at unity instead: attenuation still applies, but a boost needs a peak to prove it fits. The cap is unchanged when a peak is present.

Update the tests that expected a peakless boost, and cover the positive preamp that can no longer lift a peakless track and the attenuation that still applies.
2026-10-18 13:35:27 +00:00
austinried 746e2319b9 now playing: refresh neighbours when repeat changes
The adjacent artwork effect keys on the item and queue layout, but the predictions now also depend on the repeat mode. Toggling repeat left the cached neighbours stale, so the edge swipe still resisted until the track changed. Key the effect on the repeat mode too, with a route test that toggles repeat and expects the wrapped previous art.
2026-10-18 08:15:44 +00:00
austinried 4d08b8fb1e playback: share edge wrapping with the art predictions
Lift the wrap arithmetic out of next(), previous() and previousTrack() into nextPosition()/previousPosition() so upcomingItem()/previousItem() cannot drift from the actions again. A one-track queue no longer predicts itself as its own neighbour, online or offline: the wrap would land on the current position, and the actions are a no-op there, so the strip offered a dead swipe.
2026-10-18 08:15:44 +00:00
austinried f1628439be playback: wrap adjacent artwork with repeat
The now playing strip took its neighbours from previousItem() and upcomingItem(), which returned null at the queue edges online even when repeat would wrap. Swiping there resisted and only a hard fling advanced the track, leaving no artwork to slide in. Mirror next() and previousTrack() so the online path wraps with repeat All, and with repeat One for the next slot.
2026-10-18 07:33:58 +00:00
austinried 562ca1607b Remove leftover artist title import 2026-10-18 07:16:40 +00:00
austinried 6b0e705883 Address review: scale artist title cap and cover it with the scrim 2026-10-18 07:16:40 +00:00
austinried 7161121922 Polish downloads, settings, and detail titles
Use right/down chevrons in the download manager and order downloaded
tracks by disc and track number, matching the album screen. Move sync
concurrency to the end of the network settings, let the artist hero name
wrap to six lines, and leave the album hero title unbounded.
2026-10-18 07:16:40 +00:00
austinried a41f77d907 library: fix credited fallback and stale marker writes
Restore the both-tables-empty fallback in the credit recompute, skip
redundant marker writes, append albumless tracks after album tracks, and make
the paged artist query observe sources. Add tests for an interrupted sync and
the partial-relation migration guard.
2026-10-18 07:00:04 +00:00
austinried 9d9550be48 library: reconcile artist credits with playback and downloads
Left-join albums in the artist queue queries so albumless credited songs can
be played, downloaded and deleted. Add sortName to the starred index, persist
a per-source stale-credit marker so an interrupted sync cannot strand artist
counts or hide artists, and recompute artist play data in one grouped pass.
2026-10-18 06:42:40 +00:00
31 changed files with 2628 additions and 109 deletions
File diff suppressed because it is too large Load Diff
@@ -174,7 +174,7 @@ interface DownloadDao {
"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 = al.sourceId AND ar.id = al.artistId " +
"WHERE d.sourceId = :sourceId",
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
)
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@@ -18,6 +18,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow
internal const val PLAYLIST_SONGS_SELECT =
@@ -93,7 +94,8 @@ private const val DOWNLOADED_ARTIST_ALBUMS =
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
internal const val ARTIST_CREDITED = "artists.credited = 1"
internal const val ARTIST_CREDITED =
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " +
@@ -212,24 +214,25 @@ interface LibraryDao {
// The credited albums are the union of the lead id and the relation table, so a shared album
// counts once per artist rather than twice.
@Query(
"UPDATE artists SET " +
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)), 0), " +
"played = (SELECT MAX(a.played) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)) " +
"WHERE sourceId = :sourceId AND (" +
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)), 0) " +
"OR played IS NOT (SELECT MAX(a.played) FROM albums a " +
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)))",
"WITH credited AS (" +
"SELECT albums.artistId AS artistId, albums.id AS albumId, " +
"albums.playCount AS playCount, albums.played AS played " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION " +
"SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId" +
"), totals AS (" +
"SELECT artistId AS id, SUM(playCount) AS playCount, MAX(played) AS played " +
"FROM credited GROUP BY artistId" +
") " +
"UPDATE artists SET playCount = c.playCount, played = c.played " +
"FROM (SELECT artists.id AS id, COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.playCount != c.playCount OR artists.played IS NOT c.played)",
)
suspend fun recomputeArtistPlayData(sourceId: Long)
@@ -565,7 +568,7 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class])
@RawQuery(observedEntities = [Artist::class, Source::class])
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@RawQuery(observedEntities = [Playlist::class])
@@ -557,8 +557,8 @@ val MIGRATION_31_32 =
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
connection.execSQL(
"UPDATE `artists` SET `credited` = 0 " +
"WHERE (EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"OR EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`)) " +
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"AND EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
@@ -567,6 +567,18 @@ val MIGRATION_31_32 =
}
}
val MIGRATION_32_33 =
object : Migration(32, 33) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -632,4 +644,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_29_30,
MIGRATION_30_31,
MIGRATION_31_32,
MIGRATION_32_33,
)
@@ -34,11 +34,11 @@ private const val ARTIST_SONG_IDS =
private const val ARTIST_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
private const val ARTIST_SONGS_ORDER =
" ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -95,7 +95,7 @@ interface QueueDao {
@Query(
"SELECT COUNT(*) FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
)
suspend fun artistLength(
@@ -203,7 +203,7 @@ interface QueueDao {
@Query(
"SELECT songs.id FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
ARTIST_SONGS_ORDER,
)
@@ -29,6 +29,22 @@ interface SourcesDao {
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
suspend fun activeSourceIdOnce(): Long?
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
suspend fun creditsStale(sourceId: Long): Boolean?
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
suspend fun setCreditsStale(
sourceId: Long,
stale: Boolean,
)
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
suspend fun updateSourceDetails(
sourceId: Long,
name: String,
address: String,
)
@Insert
suspend fun insertSource(source: Source): Long
@@ -54,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 32,
version = 33,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -18,6 +18,7 @@ data class Source(
val address: String,
val isActive: Boolean = false,
val createdAt: Long,
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
)
@Entity(
@@ -263,8 +264,8 @@ data class PlaylistSong(
),
Index(
name = "index_songs_starred",
value = ["sourceId", "starred", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
)
@@ -220,8 +220,8 @@ class SourceRepository(
useTokenAuth: Boolean,
) {
val dao = db.sourcesDao()
val source = dao.sourceOnce(id) ?: return
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
if (dao.sourceOnce(id) == null) return
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
dao.upsertSubsonicSource(
SubsonicSource(
sourceId = id,
@@ -34,6 +34,8 @@ class SyncService(
private var creditsChanged = false
suspend fun sync(): SyncSummary {
val sources = db.sourcesDao()
val wasStale = sources.creditsStale(source.id) ?: true
creditsChanged = false
val artistsBefore = dbTotalChanges()
syncArtists()
@@ -41,7 +43,10 @@ class SyncService(
syncAlbums()
syncSongs()
optimize()
if (creditsChanged || artistsChanged) recomputeArtistSongCounts()
if (creditsChanged || artistsChanged || wasStale) {
recomputeArtistSongCounts()
sources.setCreditsStale(source.id, false)
}
aggregatePlayData()
syncPlaylists()
syncPlaylistSongs()
@@ -83,7 +88,13 @@ class SyncService(
albumsSynced += batch.size
}
val prunedBefore = pruned
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
pruned +=
pruneStaleIds(
seenAlbums,
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
{ library.deleteAlbums(source.id, it) },
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
)
if (pruned > prunedBefore) creditsChanged = true
pruned +=
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
@@ -101,7 +112,13 @@ class SyncService(
songsSynced += batch.size
}
val prunedBefore = pruned
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
pruned +=
pruneStaleIds(
seen,
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
{ library.deleteSongs(source.id, it) },
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
)
if (pruned > prunedBefore) creditsChanged = true
}
@@ -117,7 +134,8 @@ class SyncService(
}
db.useWriterConnection { connection ->
connection.immediateTransaction {
if (connection.albumIdsChanged(batch)) creditsChanged = true
val albumChanged = connection.albumIdsChanged(batch)
if (albumChanged) creditsChanged = true
val resolved = connection.resolveSongSortNames(batch)
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
@@ -143,7 +161,11 @@ class SyncService(
}
}
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
if (connection.totalChanges() != artistRowsBefore) creditsChanged = true
val artistRowsChanged = connection.totalChanges() != artistRowsBefore
if (albumChanged || artistRowsChanged) {
connection.markCreditsStale(batch.first().sourceId)
creditsChanged = true
}
}
}
}
@@ -167,6 +189,13 @@ class SyncService(
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
if (expected.isEmpty()) return false
@@ -319,7 +348,10 @@ class SyncService(
}
}
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
if (connection.totalChanges() != artistRowsBefore) creditsChanged = true
if (connection.totalChanges() != artistRowsBefore) {
connection.markCreditsStale(albums.first().sourceId)
creditsChanged = true
}
}
}
}
@@ -347,6 +379,7 @@ class SyncService(
seen: HashedIds,
pageOf: suspend (afterId: String) -> List<String>,
delete: suspend (Collection<String>) -> Unit,
markStale: (suspend () -> Unit)? = null,
): Int {
seen.freeze()
var afterId = ""
@@ -355,6 +388,7 @@ class SyncService(
val ids = pageOf(afterId)
if (ids.isEmpty()) return pruned
val stale = ids.filterNot { seen.contains(idHash(it)) }
if (stale.isNotEmpty()) markStale?.invoke()
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
pruned += stale.size
if (ids.size < PRUNE_PAGE) return pruned
@@ -693,12 +693,29 @@ class PlaybackController(
}
return
}
when {
current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0)
}
nextPosition(snapshot, current)?.let { jumpTo(it) }
}
private fun nextPosition(
snapshot: QueueSnapshot,
current: Long,
): Long? =
when {
current < snapshot.size - 1 -> current + 1
repeatMode != RepeatMode.Off -> 0L
else -> null
}
private fun previousPosition(
snapshot: QueueSnapshot,
current: Long,
): Long? =
when {
current > 0 -> current - 1
repeatMode == RepeatMode.All -> snapshot.size - 1
else -> null
}
private suspend fun consumeUpNext(position: Long) =
startLock.withLock {
val snapshot = snapshot ?: return@withLock
@@ -784,19 +801,12 @@ class PlaybackController(
}
return
}
when {
current > 0 -> {
jumpTo(current - 1)
}
repeatMode == RepeatMode.All -> {
jumpTo(snapshot.size - 1)
}
else -> {
player.seekTo(0)
refresh()
}
val target = previousPosition(snapshot, current)
if (target != null) {
jumpTo(target)
} else {
player.seekTo(0)
refresh()
}
}
@@ -814,10 +824,7 @@ class PlaybackController(
}
return
}
when {
current > 0 -> jumpTo(current - 1)
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
}
previousPosition(snapshot, current)?.let { jumpTo(it) }
}
fun addToQueue(
@@ -934,12 +941,16 @@ class PlaybackController(
}
val snapshot = current ?: return null
val at = position ?: return null
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
if (!isOffline) {
return nextPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
}
downloads.awaitLoaded()
val target =
downloadedAtOrAfter(snapshot, at + 1)
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
}
suspend fun previousItem(): QueueItem? {
@@ -953,12 +964,16 @@ class PlaybackController(
}
val snapshot = current ?: return null
val at = position ?: return null
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
if (!isOffline) {
return previousPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
}
downloads.awaitLoaded()
val target =
downloadedAtOrBefore(snapshot, at - 1)
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
}
private fun play(
@@ -31,7 +31,8 @@ internal fun replayGainLinearGain(
settings: ReplayGainSettings,
): Float {
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
// "no peak data" and is ignored rather than treated as silence.
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
// headroom without a peak, so a peakless track is only ever attenuated.
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
val (requestedDb, peak) =
@@ -42,7 +43,8 @@ internal fun replayGainLinearGain(
}
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
val requested = gainDb + settings.preampDb
val applied = peak?.let { min(requested, maxSafeGainDb(it)) } ?: requested
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
val applied = min(requested, maxGainDb)
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
}
@@ -257,6 +257,7 @@ fun HeroHeader(
thumbnailRef: CoverArtRef? = null,
comment: String? = null,
onSubtitleClick: (() -> Unit)? = null,
nameMaxLines: Int = 2,
) {
Column(
modifier =
@@ -278,7 +279,7 @@ fun HeroHeader(
text = name,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
maxLines = 2,
maxLines = nameMaxLines,
overflow = TextOverflow.Ellipsis,
)
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -274,7 +274,7 @@ private fun NodeRow(
leadingContent = {
IconButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
)
}
@@ -117,7 +117,7 @@ internal fun buildTree(
name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size },
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
songs = albumSongs.sortedBy { it.songSort.lowercase() },
songs = albumSongs,
)
}.sortedBy { it.sortName.lowercase() },
)
@@ -235,6 +235,7 @@ fun AlbumDetailScreen(
onMore = onMore,
topInset = topInset,
controlsModifier = controlsModifier,
nameMaxLines = Int.MAX_VALUE,
)
},
content = { rowModifier ->
@@ -48,7 +48,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -136,6 +135,7 @@ internal fun estimateScrollPx(
}
private val FADE_LEAD = 24.dp
private const val ARTIST_NAME_MAX_LINES = 6
private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
@@ -255,7 +255,13 @@ fun ArtistDetailScreen(
val albumCount = albums.size.toLong()
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
var titleHeightPx by remember { mutableFloatStateOf(0f) }
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
val titleMaxLines =
((ART_HEIGHT - TITLE_INSET - barHeight - FADE_LEAD) / lineHeight)
.toInt()
.coerceIn(1, ARTIST_NAME_MAX_LINES)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - titleHeight
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
val barFraction by remember(fadeStartPx, fadeEndPx) {
@@ -335,7 +341,7 @@ fun ArtistDetailScreen(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(ART_HEIGHT * 0.4f)
.height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
.graphicsLayer { alpha = nameScrimAlpha }
.background(
Brush.verticalGradient(
@@ -350,7 +356,13 @@ fun ArtistDetailScreen(
style = imageNameStyle,
color = Color.White,
endReserve = SHUFFLE_RESERVE,
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
maxLines = titleMaxLines,
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(TITLE_INSET)
.onSizeChanged { titleHeightPx = it.height.toFloat() },
)
FilledIconButton(
onClick = onShuffle,
@@ -517,6 +529,7 @@ private fun ArtistTitle(
style: TextStyle,
color: Color,
endReserve: Dp,
maxLines: Int,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
@@ -524,7 +537,10 @@ private fun ArtistTitle(
BoxWithConstraints(modifier) {
val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { endReserve.roundToPx() }
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
val lines =
remember(text, style, fullWidth, reserve, maxLines) {
splitTitle(measurer, text, style, fullWidth, reserve, maxLines)
}
Column {
lines.forEachIndexed { index, line ->
Text(
@@ -549,6 +565,7 @@ internal fun splitTitle(
style: TextStyle,
fullWidth: Int,
reserve: Int,
maxLines: Int = ARTIST_NAME_MAX_LINES,
): List<String> {
if (text.isBlank()) return listOf(text)
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
@@ -556,18 +573,36 @@ internal fun splitTitle(
}
val words = text.split(' ').filter { it.isNotEmpty() }
if (words.size <= 1) return listOf(text)
var count = 0
var acc = ""
for (i in words.indices) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
count = i + 1
} else {
val lines = mutableListOf<String>()
var index = 0
while (index < words.size && lines.size < maxLines) {
val remaining = words.subList(index, words.size).joinToString(" ")
if (lines.size == maxLines - 1 ||
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
) {
lines.add(remaining)
break
}
var end = index
var acc = ""
while (end < words.size) {
val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
end++
} else {
break
}
}
if (end == index) {
acc = words[index]
end = index + 1
} else if (end >= words.size && end - index > 1) {
end -= 1
acc = words.subList(index, end).joinToString(" ")
}
lines.add(acc)
index = end
}
if (count >= words.size) count = words.size - 1
if (count < 1) count = 1
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
return lines
}
@@ -244,7 +244,7 @@ fun NowPlayingRoute(
name = item.title,
)
var adjacent by remember { mutableStateOf(AdjacentArt()) }
LaunchedEffect(state.item?.id, state.layout) {
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
if (state.item == null) {
adjacent = AdjacentArt()
return@LaunchedEffect
@@ -324,14 +324,6 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
@@ -348,6 +340,14 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_music_name)) }
item {
ListItem(
+1 -1
View File
@@ -79,7 +79,7 @@
<string name="settings_replay_gain_mode_track">Track gain</string>
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain</string>
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain (tracks without peak data cannot be boosted)</string>
<string name="settings_replay_gain_preamp_reset">Reset</string>
<string name="settings_replay_gain_preamp_title">Preamp</string>
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
@@ -64,6 +64,8 @@ class MigrationsTest {
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_songs_starred", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_songs_starred", "starred").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
@@ -229,6 +231,13 @@ class MigrationsTest {
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'a1', 'Song')",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (1, 's1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
@@ -237,6 +246,32 @@ class MigrationsTest {
}
}
@Test
fun theCreditedBackfillKeepsArtistsVisibleWhileRelationsAreIncomplete() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 31)
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) VALUES (1, 'a1', 'Alpha', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
}
}
private fun text(
connection: SQLiteConnection,
sql: String,
@@ -975,6 +975,53 @@ class DownloadRepositoryTest {
assertFalse(file(1, "s2").exists())
}
@Test
fun downloadedSongsFollowTheAlbumDiscAndTrackOrder() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s0",
albumId = "al1",
artistId = "ar1",
title = "Alpha",
album = "Album",
artist = "Artist",
duration = 100,
track = 3,
disc = 1,
starred = null,
genre = null,
),
Song(
sourceId = 1,
id = "s3",
albumId = "al1",
artistId = "ar1",
title = "Zulu",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 2,
starred = null,
genre = null,
),
),
)
listOf("s0", "s1", "s2", "s3").forEach {
db.downloadDao().upsert(SongDownload(1, it, DownloadStatus.Completed))
}
}
val songs = runBlocking { repository.downloadedSongs(1).first() }
assertEquals(listOf("s1", "s2", "s0", "s3"), songs.map { it.songId })
}
@Test
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
runTest {
@@ -1167,6 +1167,71 @@ class SyncServiceTest {
)
}
@Test
fun anInterruptedSyncLeavesTheCreditMarkerStale() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Alpha"), artist("combo", "Alpha & Beta")),
albums = listOf(album("al1")),
songs = listOf(song("s1")),
songsFailure = java.io.IOException("boom"),
)
runCatching { SyncService(db, source).sync() }
assertEquals(true, db.sourcesDao().creditsStale(1))
assertEquals(
listOf("a1", "combo"),
db
.libraryDao()
.artistsByName(1, 0, "")
.allRows()
.map { it.id },
)
source.songsFailure = null
SyncService(db, source).sync()
assertEquals(false, db.sourcesDao().creditsStale(1))
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.allRows()
.map { it.id },
)
}
@Test
fun aStaleCreditMarkerForcesARecomputeOnTheNextSync() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A")),
albums = listOf(album("al1")),
songs = listOf(song("s1")),
)
SyncService(db, source).sync()
assertEquals(false, db.sourcesDao().creditsStale(1))
db.sourcesDao().setCreditsStale(1, true)
db.libraryDao().upsertArtists(
listOf(db.libraryDao().artistOnce(1, "a1")!!.copy(songCount = 0, credited = false)),
)
SyncService(db, source).sync()
val artist = db.libraryDao().artistOnce(1, "a1")!!
assertEquals(1L, artist.songCount)
assertEquals(true, artist.credited)
assertEquals(false, db.sourcesDao().creditsStale(1))
}
@Test
fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() =
runTest {
@@ -1251,6 +1316,9 @@ class SyncServiceTest {
SyncService(db, source).sync()
assertEquals(2L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(listOf("s1", "s2"), db.queueDao().artistSongIds(1, "a1"))
assertEquals(2L, db.queueDao().artistLength(1, "a1"))
assertEquals(2, db.queueDao().artistSongs(1, "a1", 0, 10).size)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
@@ -1483,6 +1551,7 @@ private class FakeMusicSource(
var songs: List<Song> = emptyList(),
var playlists: List<Playlist> = emptyList(),
var playlistSongs: List<PlaylistSong> = emptyList(),
var songsFailure: Throwable? = null,
private val batchSize: Int = 1000,
) : MusicSource {
val requestedPlaylistIds = mutableListOf<List<String>>()
@@ -1505,7 +1574,12 @@ private class FakeMusicSource(
override fun albums(): Flow<List<Album>> = batches(albums)
override fun songs(): Flow<List<Song>> = batches(songs)
override fun songs(): Flow<List<Song>> =
if (songsFailure == null) {
batches(songs)
} else {
flow { throw songsFailure!! }
}
override fun playlists(): Flow<List<Playlist>> = batches(playlists)
@@ -218,6 +218,61 @@ class PlaybackControllerTest {
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun previousItemWrapsToTheEndWithRepeatAll() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertEquals("s3", runBlocking { controller.previousItem()?.id })
}
@Test
fun upcomingItemIsNullAtTheEndOfTheQueue() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
assertNull(runBlocking { controller.upcomingItem() })
}
@Test
fun upcomingItemWrapsToTheStartWithRepeatAll() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertEquals("s1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun upcomingItemWrapsToTheStartWithRepeatOne() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.cycleRepeat()
controller.cycleRepeat()
assertEquals(RepeatMode.One, controller.state.value.repeat)
assertEquals("s1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
seedAlbum(3, sourceId = 1)
@@ -2207,6 +2262,38 @@ class PlaybackControllerTest {
)
}
@Test
fun aSingleTrackQueueHasNoAdjacentArtwork() {
seedAlbum(1, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertNull(runBlocking { controller.upcomingItem() })
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun aSingleDownloadedTrackHasNoAdjacentArtworkWhenOffline() {
seedAlbum(1, sourceId = 1)
markDownloaded(1, "s1")
setOffline()
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertNull(runBlocking { controller.upcomingItem() })
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun repeatAllLoopsAfterEndingWithRepeatOff() {
seedAlbum(2, sourceId = 1)
@@ -53,7 +53,9 @@ class ReplayGainControllerTest {
fun theLatestItemLeavesItsGainApplied() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = 6.0206f))
controller.applyItem(item("a", trackGain = 6.0206f, trackPeak = 0.5f))
assertEquals(2f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
@@ -62,6 +64,7 @@ class ReplayGainControllerTest {
private fun item(
id: String,
trackGain: Float?,
trackPeak: Float? = null,
) = queueMediaItem(
QueueItem(
id = id,
@@ -69,7 +72,7 @@ class ReplayGainControllerTest {
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
),
local = null,
stream = "http://server/$id",
@@ -128,7 +128,7 @@ class ReplayGainProcessorTest {
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f)))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f, trackPeak = 0.5f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(2000), readShorts(processor.getOutput()))
}
@@ -146,6 +146,7 @@ class ReplayGainProcessorTest {
private fun item(
id: String,
trackGain: Float,
trackPeak: Float? = null,
): MediaItem =
queueMediaItem(
QueueItem(
@@ -154,7 +155,7 @@ class ReplayGainProcessorTest {
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain),
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
),
local = null,
stream = "http://server/$id",
@@ -76,11 +76,35 @@ class ReplayGainTest {
@Test
fun thePreampShiftsTheAppliedGain() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -12.0412f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
),
TOLERANCE,
)
}
@Test
fun aPositivePreampCannotBoostWithoutAPeak() {
assertEquals(
1f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
ReplayGainSettings(ReplayGainMode.Track, 12f),
),
TOLERANCE,
)
}
@Test
fun aMissingPeakStillAppliesAnAttenuation() {
assertEquals(
0.31622776f,
replayGainLinearGain(
ReplayGain(trackGain = -10f),
ReplayGainSettings(ReplayGainMode.Track, 0f),
),
TOLERANCE,
)
@@ -111,12 +135,11 @@ class ReplayGainTest {
}
@Test
fun aMissingOrZeroPeakDoesNotCap() {
fun aMissingOrZeroPeakAllowsNoBoost() {
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
val expected = 3.9810717f
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
}
@Test
@@ -144,9 +167,9 @@ class ReplayGainTest {
}
@Test
fun aNonFinitePeakDoesNotCap() {
fun aNonFinitePeakAllowsNoBoost() {
assertEquals(
3.9810717f,
1f,
replayGainLinearGain(
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
ReplayGainSettings(ReplayGainMode.Track, 0f),
@@ -37,6 +37,29 @@ class DownloadTreeTest {
assertEquals(36L, tree.bytes)
}
@Test
fun songsKeepTheOrderTheRepositoryReturns() {
val tree =
buildTree(
listOf(
song("s2", "Two", "ar1", "Artist", "al1", "First", size = 20),
song("s1", "One", "ar1", "Artist", "al1", "First", size = 10),
),
"Unknown artist",
"Unknown album",
)
assertEquals(
listOf("Two", "One"),
tree.artists
.single()
.albums
.single()
.songs
.map { it.title },
)
}
@Test
fun aSongWithNoArtistOrAlbumIsStillShown() {
val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3)), "Unknown artist", "Unknown album")
@@ -49,6 +49,21 @@ class HangingTitleTest {
assertEquals(listOf("Boards"), splitTitle(measurer!!, "Boards", style, fullWidth, reserve))
}
@Test
fun aLongNameWrapsToAtMostSixLines() {
setUpMeasurements()
val text = (1..200).joinToString(" ") { "word$it" }
val lines = splitTitle(measurer!!, text, style, fullWidth, reserve)
assertEquals(6, lines.size)
lines.dropLast(1).forEach { line ->
val width = measurer!!.measure(AnnotatedString(line), style, maxLines = 1).size.width
assertTrue("every line before the last must fit the full width", width <= fullWidth)
}
assertTrue(lines.last().isNotEmpty())
}
@Test
fun theLastLineStopsBeforeTheReservedWidth() {
setUpMeasurements()
@@ -0,0 +1,190 @@
package com.subtracks.ui.playback
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.StarType
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
import java.io.File
import java.util.concurrent.Executors
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NowPlayingRouteTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
private lateinit var db: SubtracksDatabase
private lateinit var handle: FakePlayerHandle
private lateinit var controller: PlaybackController
private lateinit var library: LibraryRepository
private lateinit var downloads: DownloadRepository
private val prefs = fakeUserPreferences()
private val serverActions =
object : ServerActionSink {
override suspend fun nowPlaying(songId: String) = Unit
override suspend fun scrobble(
songId: String,
time: Long,
) = Unit
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) = Unit
}
private fun setUpState() {
val context = ApplicationProvider.getApplicationContext<Context>()
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
val queues = QueueRepository(db)
downloads =
DownloadRepository(
db,
sources,
FakeDownloadEngine(),
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artworkStore,
artworkFetcher = { ByteArray(0) },
scope = repoScope,
).also { it.start() }
library = LibraryRepository(db, sources, serverActions, scope = repoScope)
handle = FakePlayerHandle()
controller =
PlaybackController(
sources,
queues,
FakePlayerConnection(handle),
downloads,
showMessage = {},
scope = controllerScope,
)
}
private fun seedAlbum(count: Int) {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
db.libraryDao().upsertSongs(
(1..count).map { track ->
Song(
sourceId = 1,
id = "s$track",
albumId = "al1",
artistId = "ar1",
title = "Song $track",
album = "Album",
artist = "Artist",
duration = 100,
track = track.toLong(),
disc = 1,
starred = null,
genre = null,
)
},
)
}
}
@After
fun tearDown() {
cancelAndJoinBlocking(controllerScope, repoScope)
db.close()
dispatcher.close()
}
@Test
fun togglingRepeatRefreshesTheAdjacentArtwork() {
setUpState()
seedAlbum(3)
controller.playAlbum(1, "al1", 0)
awaitUntil("waiting for the first track") {
controller.state.value.item
?.id == "s1"
}
composeRule.setContent {
SubtracksTheme {
NowPlayingRoute(
onBack = {},
onQueue = {},
onViewAlbum = {},
onViewArtist = {},
setStar = { _, _, _ -> },
controller = controller,
libraryRepository = library,
downloadRepository = downloads,
userPreferences = prefs,
)
}
}
composeRule.waitUntil(TEST_TIMEOUT_MS) {
composeRule
.onAllNodesWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true)
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule
.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true)
.assertDoesNotExist()
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
composeRule.waitUntil(TEST_TIMEOUT_MS) {
composeRule
.onAllNodesWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true)
.fetchSemanticsNodes()
.isNotEmpty()
}
}
}
+1 -1
View File
@@ -98,7 +98,7 @@ Server writes — now-playing, scrobbles and stars — go through a `ServerActio
The media notification carries a star button for the playing track. `PlaybackService` adds a `com.subtracks.STAR` session command and a `CommandButton` to the `MediaSession`, and drives its filled/unfilled icon from the playing song's Room row. That path — `MediaSession.Callback`, `SessionCommand`, `CommandButton`, `SessionResult`/`SessionError` and `MediaSession.setMediaButtonPreferences` — is annotated `@UnstableApi` in Media3 1.11.1 and is therefore opted into explicitly: when bumping Media3, re-check those signatures and the notification's button resolution. The stable `setCustomLayout` is not a substitute here, because the notification manager only refreshes on `onMediaButtonPreferencesChanged`, so the icon would not update mid-playback.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one.
ReplayGain is applied at the audio sink. The gain the server reports per song (`trackGain`/`albumGain` and their peaks; `baseGain` and `fallbackGain` are ignored) is synced into the song row, carried onto the media item, and applied by a `ReplayGainProcessor` in `DefaultAudioSink`'s processor chain, installed through a `RenderersFactory`. The gain is set from `AudioProcessor.flush(StreamMetadata)`, which Media3 1.11 populates with the playback `Timeline` and the current period id (`androidx/media#418`) and calls on every sink configuration — including a media-item transition at an unchanged format — so a new item's gain is in place before any of its buffers are processed. That timing is the point: `Player.Listener.onMediaItemTransition` arrives on the app thread after the playback thread has already rendered, which is too late. When bumping Media3, re-check that `flush` still carries the timeline and period, and that the chain is reached at all: float output and audio offload both bypass it, which is why float output stays off. The mode (track/album/off) and a preamp are DataStore preferences read by `PlaybackService`, and the anti-clip cap uses the peak when the server sends one: without a peak there is no headroom to check, so a peakless track is only ever attenuated.
## Data model