mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
Compare commits
10
Commits
c64cdd5172
...
916da72339
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
916da72339 | ||
|
|
87dbabb496 | ||
|
|
746e2319b9 | ||
|
|
4d08b8fb1e | ||
|
|
f1628439be | ||
|
|
562ca1607b | ||
|
|
6b0e705883 | ||
|
|
7161121922 | ||
|
|
a41f77d907 | ||
|
|
9d9550be48 |
+1900
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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user