mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 23:39:15 +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 " +
|
"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 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 " +
|
"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>>
|
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.PlaylistSong
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
internal const val PLAYLIST_SONGS_SELECT =
|
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 " +
|
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
|
||||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
|
"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 =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"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
|
// 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.
|
// counts once per artist rather than twice.
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET " +
|
"WITH credited AS (" +
|
||||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a " +
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
|
"albums.playCount AS playCount, albums.played AS played " +
|
||||||
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)), 0), " +
|
"UNION " +
|
||||||
"played = (SELECT MAX(a.played) FROM albums a " +
|
"SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
|
"FROM album_artists JOIN albums " +
|
||||||
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)) " +
|
"WHERE album_artists.sourceId = :sourceId" +
|
||||||
"WHERE sourceId = :sourceId AND (" +
|
"), totals AS (" +
|
||||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a " +
|
"SELECT artistId AS id, SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
|
"FROM credited GROUP BY artistId" +
|
||||||
"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) " +
|
"UPDATE artists SET playCount = c.playCount, played = c.played " +
|
||||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a " +
|
"FROM (SELECT artists.id AS id, COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.id IN (" +
|
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||||
"SELECT albumId FROM album_artists WHERE sourceId = artists.sourceId AND artistId = artists.id " +
|
"WHERE artists.sourceId = :sourceId) c " +
|
||||||
"UNION SELECT id FROM albums WHERE sourceId = artists.sourceId AND artistId = artists.id)))",
|
"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)
|
suspend fun recomputeArtistPlayData(sourceId: Long)
|
||||||
|
|
||||||
@@ -565,7 +568,7 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Album::class])
|
@RawQuery(observedEntities = [Album::class])
|
||||||
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Artist::class])
|
@RawQuery(observedEntities = [Artist::class, Source::class])
|
||||||
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Playlist::class])
|
@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("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
|
||||||
connection.execSQL(
|
connection.execSQL(
|
||||||
"UPDATE `artists` SET `credited` = 0 " +
|
"UPDATE `artists` SET `credited` = 0 " +
|
||||||
"WHERE (EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
"OR EXISTS (SELECT 1 FROM `song_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 " +
|
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
|
||||||
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
|
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
|
"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.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -632,4 +644,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_29_30,
|
MIGRATION_29_30,
|
||||||
MIGRATION_30_31,
|
MIGRATION_30_31,
|
||||||
MIGRATION_31_32,
|
MIGRATION_31_32,
|
||||||
|
MIGRATION_32_33,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ private const val ARTIST_SONG_IDS =
|
|||||||
|
|
||||||
private const val ARTIST_SONGS_SELECT =
|
private const val ARTIST_SONGS_SELECT =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"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"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
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 =
|
private const val SONG_SQL =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
@@ -95,7 +95,7 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(*) FROM songs " +
|
"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",
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||||
)
|
)
|
||||||
suspend fun artistLength(
|
suspend fun artistLength(
|
||||||
@@ -203,7 +203,7 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs " +
|
"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" +
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||||
ARTIST_SONGS_ORDER,
|
ARTIST_SONGS_ORDER,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ interface SourcesDao {
|
|||||||
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||||
suspend fun activeSourceIdOnce(): Long?
|
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
|
@Insert
|
||||||
suspend fun insertSource(source: Source): Long
|
suspend fun insertSource(source: Source): Long
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 32,
|
version = 33,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ data class Source(
|
|||||||
val address: String,
|
val address: String,
|
||||||
val isActive: Boolean = false,
|
val isActive: Boolean = false,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
|
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -263,8 +264,8 @@ data class PlaylistSong(
|
|||||||
),
|
),
|
||||||
Index(
|
Index(
|
||||||
name = "index_songs_starred",
|
name = "index_songs_starred",
|
||||||
value = ["sourceId", "starred", "id"],
|
value = ["sourceId", "starred", "sortName", "id"],
|
||||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -220,8 +220,8 @@ class SourceRepository(
|
|||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
) {
|
) {
|
||||||
val dao = db.sourcesDao()
|
val dao = db.sourcesDao()
|
||||||
val source = dao.sourceOnce(id) ?: return
|
if (dao.sourceOnce(id) == null) return
|
||||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||||
dao.upsertSubsonicSource(
|
dao.upsertSubsonicSource(
|
||||||
SubsonicSource(
|
SubsonicSource(
|
||||||
sourceId = id,
|
sourceId = id,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ class SyncService(
|
|||||||
private var creditsChanged = false
|
private var creditsChanged = false
|
||||||
|
|
||||||
suspend fun sync(): SyncSummary {
|
suspend fun sync(): SyncSummary {
|
||||||
|
val sources = db.sourcesDao()
|
||||||
|
val wasStale = sources.creditsStale(source.id) ?: true
|
||||||
creditsChanged = false
|
creditsChanged = false
|
||||||
val artistsBefore = dbTotalChanges()
|
val artistsBefore = dbTotalChanges()
|
||||||
syncArtists()
|
syncArtists()
|
||||||
@@ -41,7 +43,10 @@ class SyncService(
|
|||||||
syncAlbums()
|
syncAlbums()
|
||||||
syncSongs()
|
syncSongs()
|
||||||
optimize()
|
optimize()
|
||||||
if (creditsChanged || artistsChanged) recomputeArtistSongCounts()
|
if (creditsChanged || artistsChanged || wasStale) {
|
||||||
|
recomputeArtistSongCounts()
|
||||||
|
sources.setCreditsStale(source.id, false)
|
||||||
|
}
|
||||||
aggregatePlayData()
|
aggregatePlayData()
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
@@ -83,7 +88,13 @@ class SyncService(
|
|||||||
albumsSynced += batch.size
|
albumsSynced += batch.size
|
||||||
}
|
}
|
||||||
val prunedBefore = pruned
|
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
|
if (pruned > prunedBefore) creditsChanged = true
|
||||||
pruned +=
|
pruned +=
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
@@ -101,7 +112,13 @@ class SyncService(
|
|||||||
songsSynced += batch.size
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
val prunedBefore = pruned
|
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
|
if (pruned > prunedBefore) creditsChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +134,8 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
if (connection.albumIdsChanged(batch)) creditsChanged = true
|
val albumChanged = connection.albumIdsChanged(batch)
|
||||||
|
if (albumChanged) creditsChanged = true
|
||||||
val resolved = connection.resolveSongSortNames(batch)
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
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() }
|
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 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 {
|
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
|
||||||
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
|
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
|
||||||
if (expected.isEmpty()) return false
|
if (expected.isEmpty()) return false
|
||||||
@@ -319,7 +348,10 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
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,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterId: String) -> List<String>,
|
pageOf: suspend (afterId: String) -> List<String>,
|
||||||
delete: suspend (Collection<String>) -> Unit,
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
|
markStale: (suspend () -> Unit)? = null,
|
||||||
): Int {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterId = ""
|
var afterId = ""
|
||||||
@@ -355,6 +388,7 @@ class SyncService(
|
|||||||
val ids = pageOf(afterId)
|
val ids = pageOf(afterId)
|
||||||
if (ids.isEmpty()) return pruned
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
|
if (stale.isNotEmpty()) markStale?.invoke()
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
pruned += stale.size
|
pruned += stale.size
|
||||||
if (ids.size < PRUNE_PAGE) return pruned
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
|
|||||||
@@ -693,12 +693,29 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
||||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
|
||||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) =
|
private suspend fun consumeUpNext(position: Long) =
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
@@ -784,19 +801,12 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
val target = previousPosition(snapshot, current)
|
||||||
current > 0 -> {
|
if (target != null) {
|
||||||
jumpTo(current - 1)
|
jumpTo(target)
|
||||||
}
|
} else {
|
||||||
|
player.seekTo(0)
|
||||||
repeatMode == RepeatMode.All -> {
|
refresh()
|
||||||
jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
player.seekTo(0)
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,10 +824,7 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
||||||
current > 0 -> jumpTo(current - 1)
|
|
||||||
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueue(
|
fun addToQueue(
|
||||||
@@ -934,12 +941,16 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: 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()
|
downloads.awaitLoaded()
|
||||||
val target =
|
val target =
|
||||||
downloadedAtOrAfter(snapshot, at + 1)
|
downloadedAtOrAfter(snapshot, at + 1)
|
||||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
?: 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? {
|
suspend fun previousItem(): QueueItem? {
|
||||||
@@ -953,12 +964,16 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: 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()
|
downloads.awaitLoaded()
|
||||||
val target =
|
val target =
|
||||||
downloadedAtOrBefore(snapshot, at - 1)
|
downloadedAtOrBefore(snapshot, at - 1)
|
||||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
?: 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(
|
private fun play(
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ internal fun replayGainLinearGain(
|
|||||||
settings: ReplayGainSettings,
|
settings: ReplayGainSettings,
|
||||||
): Float {
|
): Float {
|
||||||
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
// 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 trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
val (requestedDb, peak) =
|
val (requestedDb, peak) =
|
||||||
@@ -42,7 +43,8 @@ internal fun replayGainLinearGain(
|
|||||||
}
|
}
|
||||||
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||||
val requested = gainDb + settings.preampDb
|
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
|
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ fun HeroHeader(
|
|||||||
thumbnailRef: CoverArtRef? = null,
|
thumbnailRef: CoverArtRef? = null,
|
||||||
comment: String? = null,
|
comment: String? = null,
|
||||||
onSubtitleClick: (() -> Unit)? = null,
|
onSubtitleClick: (() -> Unit)? = null,
|
||||||
|
nameMaxLines: Int = 2,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -278,7 +279,7 @@ fun HeroHeader(
|
|||||||
text = name,
|
text = name,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = nameMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.Close
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -274,7 +274,7 @@ private fun NodeRow(
|
|||||||
leadingContent = {
|
leadingContent = {
|
||||||
IconButton(onClick = onToggle) {
|
IconButton(onClick = onToggle) {
|
||||||
Icon(
|
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),
|
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ internal fun buildTree(
|
|||||||
name = albumKey.second.ifBlank { unknownAlbum },
|
name = albumKey.second.ifBlank { unknownAlbum },
|
||||||
bytes = albumSongs.sumOf { it.size },
|
bytes = albumSongs.sumOf { it.size },
|
||||||
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
|
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
|
||||||
songs = albumSongs.sortedBy { it.songSort.lowercase() },
|
songs = albumSongs,
|
||||||
)
|
)
|
||||||
}.sortedBy { it.sortName.lowercase() },
|
}.sortedBy { it.sortName.lowercase() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ fun AlbumDetailScreen(
|
|||||||
onMore = onMore,
|
onMore = onMore,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
controlsModifier = controlsModifier,
|
controlsModifier = controlsModifier,
|
||||||
|
nameMaxLines = Int.MAX_VALUE,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
content = { rowModifier ->
|
content = { rowModifier ->
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
@@ -136,6 +135,7 @@ internal fun estimateScrollPx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val FADE_LEAD = 24.dp
|
private val FADE_LEAD = 24.dp
|
||||||
|
private const val ARTIST_NAME_MAX_LINES = 6
|
||||||
private const val THEME_TRANSITION_MS = 100
|
private const val THEME_TRANSITION_MS = 100
|
||||||
private const val SCRIM_FADE_MS = 180
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||||
@@ -255,7 +255,13 @@ fun ArtistDetailScreen(
|
|||||||
val albumCount = albums.size.toLong()
|
val albumCount = albums.size.toLong()
|
||||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
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 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 fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
||||||
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
||||||
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
||||||
@@ -335,7 +341,7 @@ fun ArtistDetailScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ART_HEIGHT * 0.4f)
|
.height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
|
||||||
.graphicsLayer { alpha = nameScrimAlpha }
|
.graphicsLayer { alpha = nameScrimAlpha }
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
@@ -350,7 +356,13 @@ fun ArtistDetailScreen(
|
|||||||
style = imageNameStyle,
|
style = imageNameStyle,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
endReserve = SHUFFLE_RESERVE,
|
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(
|
FilledIconButton(
|
||||||
onClick = onShuffle,
|
onClick = onShuffle,
|
||||||
@@ -517,6 +529,7 @@ private fun ArtistTitle(
|
|||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
color: Color,
|
color: Color,
|
||||||
endReserve: Dp,
|
endReserve: Dp,
|
||||||
|
maxLines: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
@@ -524,7 +537,10 @@ private fun ArtistTitle(
|
|||||||
BoxWithConstraints(modifier) {
|
BoxWithConstraints(modifier) {
|
||||||
val fullWidth = with(density) { maxWidth.roundToPx() }
|
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||||
val reserve = with(density) { endReserve.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 {
|
Column {
|
||||||
lines.forEachIndexed { index, line ->
|
lines.forEachIndexed { index, line ->
|
||||||
Text(
|
Text(
|
||||||
@@ -549,6 +565,7 @@ internal fun splitTitle(
|
|||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
fullWidth: Int,
|
fullWidth: Int,
|
||||||
reserve: Int,
|
reserve: Int,
|
||||||
|
maxLines: Int = ARTIST_NAME_MAX_LINES,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
if (text.isBlank()) return listOf(text)
|
if (text.isBlank()) return listOf(text)
|
||||||
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
|
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() }
|
val words = text.split(' ').filter { it.isNotEmpty() }
|
||||||
if (words.size <= 1) return listOf(text)
|
if (words.size <= 1) return listOf(text)
|
||||||
var count = 0
|
val lines = mutableListOf<String>()
|
||||||
var acc = ""
|
var index = 0
|
||||||
for (i in words.indices) {
|
while (index < words.size && lines.size < maxLines) {
|
||||||
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
|
val remaining = words.subList(index, words.size).joinToString(" ")
|
||||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
if (lines.size == maxLines - 1 ||
|
||||||
acc = candidate
|
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
|
||||||
count = i + 1
|
) {
|
||||||
} else {
|
lines.add(remaining)
|
||||||
break
|
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
|
return lines
|
||||||
if (count < 1) count = 1
|
|
||||||
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ fun NowPlayingRoute(
|
|||||||
name = item.title,
|
name = item.title,
|
||||||
)
|
)
|
||||||
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
||||||
LaunchedEffect(state.item?.id, state.layout) {
|
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
|
||||||
if (state.item == null) {
|
if (state.item == null) {
|
||||||
adjacent = AdjacentArt()
|
adjacent = AdjacentArt()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
|
|||||||
@@ -324,14 +324,6 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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 {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
@@ -348,6 +340,14 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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 { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<string name="settings_replay_gain_mode_track">Track gain</string>
|
<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_decrease">Lower preamp</string>
|
||||||
<string name="settings_replay_gain_preamp_increase">Raise 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_reset">Reset</string>
|
||||||
<string name="settings_replay_gain_preamp_title">Preamp</string>
|
<string name="settings_replay_gain_preamp_title">Preamp</string>
|
||||||
<string name="settings_replay_gain_preamp_value">%1$s dB</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_starred", "starred").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").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("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
|
||||||
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
|
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
|
||||||
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").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) " +
|
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||||
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
|
"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)
|
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(
|
private fun text(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
sql: String,
|
sql: String,
|
||||||
|
|||||||
@@ -975,6 +975,53 @@ class DownloadRepositoryTest {
|
|||||||
assertFalse(file(1, "s2").exists())
|
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
|
@Test
|
||||||
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
|
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
|
||||||
runTest {
|
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
|
@Test
|
||||||
fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() =
|
fun everyAlbumArtistAndTrackArtistReachesTheSharedAlbum() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -1251,6 +1316,9 @@ class SyncServiceTest {
|
|||||||
SyncService(db, source).sync()
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
assertEquals(2L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
|
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, "s1", DownloadStatus.Completed))
|
||||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||||
|
|
||||||
@@ -1483,6 +1551,7 @@ private class FakeMusicSource(
|
|||||||
var songs: List<Song> = emptyList(),
|
var songs: List<Song> = emptyList(),
|
||||||
var playlists: List<Playlist> = emptyList(),
|
var playlists: List<Playlist> = emptyList(),
|
||||||
var playlistSongs: List<PlaylistSong> = emptyList(),
|
var playlistSongs: List<PlaylistSong> = emptyList(),
|
||||||
|
var songsFailure: Throwable? = null,
|
||||||
private val batchSize: Int = 1000,
|
private val batchSize: Int = 1000,
|
||||||
) : MusicSource {
|
) : MusicSource {
|
||||||
val requestedPlaylistIds = mutableListOf<List<String>>()
|
val requestedPlaylistIds = mutableListOf<List<String>>()
|
||||||
@@ -1505,7 +1574,12 @@ private class FakeMusicSource(
|
|||||||
|
|
||||||
override fun albums(): Flow<List<Album>> = batches(albums)
|
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)
|
override fun playlists(): Flow<List<Playlist>> = batches(playlists)
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,61 @@ class PlaybackControllerTest {
|
|||||||
assertNull(runBlocking { controller.previousItem() })
|
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
|
@Test
|
||||||
fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
|
fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
|
||||||
seedAlbum(3, sourceId = 1)
|
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
|
@Test
|
||||||
fun repeatAllLoopsAfterEndingWithRepeatOff() {
|
fun repeatAllLoopsAfterEndingWithRepeatOff() {
|
||||||
seedAlbum(2, sourceId = 1)
|
seedAlbum(2, sourceId = 1)
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ class ReplayGainControllerTest {
|
|||||||
fun theLatestItemLeavesItsGainApplied() {
|
fun theLatestItemLeavesItsGainApplied() {
|
||||||
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
|
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))
|
controller.applyItem(item("b", trackGain = -6.0206f))
|
||||||
|
|
||||||
assertEquals(0.5f, applied.last(), TOLERANCE)
|
assertEquals(0.5f, applied.last(), TOLERANCE)
|
||||||
@@ -62,6 +64,7 @@ class ReplayGainControllerTest {
|
|||||||
private fun item(
|
private fun item(
|
||||||
id: String,
|
id: String,
|
||||||
trackGain: Float?,
|
trackGain: Float?,
|
||||||
|
trackPeak: Float? = null,
|
||||||
) = queueMediaItem(
|
) = queueMediaItem(
|
||||||
QueueItem(
|
QueueItem(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -69,7 +72,7 @@ class ReplayGainControllerTest {
|
|||||||
artist = null,
|
artist = null,
|
||||||
album = null,
|
album = null,
|
||||||
coverArtId = null,
|
coverArtId = null,
|
||||||
replayGain = ReplayGain(trackGain = trackGain),
|
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
|
||||||
),
|
),
|
||||||
local = null,
|
local = null,
|
||||||
stream = "http://server/$id",
|
stream = "http://server/$id",
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class ReplayGainProcessorTest {
|
|||||||
processor.queueInput(sixteenBit(1000))
|
processor.queueInput(sixteenBit(1000))
|
||||||
assertEquals(listOf(500), readShorts(processor.getOutput()))
|
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))
|
processor.queueInput(sixteenBit(1000))
|
||||||
assertEquals(listOf(2000), readShorts(processor.getOutput()))
|
assertEquals(listOf(2000), readShorts(processor.getOutput()))
|
||||||
}
|
}
|
||||||
@@ -146,6 +146,7 @@ class ReplayGainProcessorTest {
|
|||||||
private fun item(
|
private fun item(
|
||||||
id: String,
|
id: String,
|
||||||
trackGain: Float,
|
trackGain: Float,
|
||||||
|
trackPeak: Float? = null,
|
||||||
): MediaItem =
|
): MediaItem =
|
||||||
queueMediaItem(
|
queueMediaItem(
|
||||||
QueueItem(
|
QueueItem(
|
||||||
@@ -154,7 +155,7 @@ class ReplayGainProcessorTest {
|
|||||||
artist = null,
|
artist = null,
|
||||||
album = null,
|
album = null,
|
||||||
coverArtId = null,
|
coverArtId = null,
|
||||||
replayGain = ReplayGain(trackGain = trackGain),
|
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
|
||||||
),
|
),
|
||||||
local = null,
|
local = null,
|
||||||
stream = "http://server/$id",
|
stream = "http://server/$id",
|
||||||
|
|||||||
@@ -76,11 +76,35 @@ class ReplayGainTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun thePreampShiftsTheAppliedGain() {
|
fun thePreampShiftsTheAppliedGain() {
|
||||||
|
assertEquals(
|
||||||
|
0.5f,
|
||||||
|
replayGainLinearGain(
|
||||||
|
ReplayGain(trackGain = -12.0412f),
|
||||||
|
ReplayGainSettings(ReplayGainMode.Track, 6.0206f),
|
||||||
|
),
|
||||||
|
TOLERANCE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPositivePreampCannotBoostWithoutAPeak() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1f,
|
1f,
|
||||||
replayGainLinearGain(
|
replayGainLinearGain(
|
||||||
ReplayGain(trackGain = -6.0206f),
|
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,
|
TOLERANCE,
|
||||||
)
|
)
|
||||||
@@ -111,12 +135,11 @@ class ReplayGainTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aMissingOrZeroPeakDoesNotCap() {
|
fun aMissingOrZeroPeakAllowsNoBoost() {
|
||||||
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
val settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||||
val expected = 3.9810717f
|
|
||||||
|
|
||||||
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
|
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f), settings), TOLERANCE)
|
||||||
assertEquals(expected, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
|
assertEquals(1f, replayGainLinearGain(ReplayGain(trackGain = 12f, trackPeak = 0f), settings), TOLERANCE)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -144,9 +167,9 @@ class ReplayGainTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aNonFinitePeakDoesNotCap() {
|
fun aNonFinitePeakAllowsNoBoost() {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
3.9810717f,
|
1f,
|
||||||
replayGainLinearGain(
|
replayGainLinearGain(
|
||||||
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
|
ReplayGain(trackGain = 12f, trackPeak = Float.POSITIVE_INFINITY),
|
||||||
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
ReplayGainSettings(ReplayGainMode.Track, 0f),
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ class DownloadTreeTest {
|
|||||||
assertEquals(36L, tree.bytes)
|
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
|
@Test
|
||||||
fun aSongWithNoArtistOrAlbumIsStillShown() {
|
fun aSongWithNoArtistOrAlbumIsStillShown() {
|
||||||
val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3)), "Unknown artist", "Unknown album")
|
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))
|
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
|
@Test
|
||||||
fun theLastLineStopsBeforeTheReservedWidth() {
|
fun theLastLineStopsBeforeTheReservedWidth() {
|
||||||
setUpMeasurements()
|
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.
|
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
|
## Data model
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user