keep playback running when rebuilding the offline window, and order downloads newest-first

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 8848e951e4
commit 2061ebafca
12 changed files with 1680 additions and 66 deletions
File diff suppressed because it is too large Load Diff
@@ -709,7 +709,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
)
fun downloadedSongs(sourceId: Long): Flow<List<AlbumSongItem>>
@@ -717,7 +717,7 @@ interface LibraryDao {
"SELECT songs.id FROM songs " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
@@ -726,10 +726,31 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
)
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@Query(
"SELECT albums.* FROM albums " +
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE albums.sourceId = :sourceId " +
"GROUP BY albums.sourceId, albums.id " +
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id",
)
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query(
"SELECT artists.* FROM artists " +
"JOIN albums ON albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE artists.sourceId = :sourceId " +
"GROUP BY artists.sourceId, artists.id " +
"ORDER BY MAX(d.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
)
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
@@ -768,13 +789,15 @@ interface LibraryDao {
@Query(
"SELECT COUNT(*) FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
)
suspend fun downloadedSongOrdinal(
sourceId: Long,
title: String,
id: String,
): Long
@@ -352,6 +352,15 @@ val MIGRATION_23_24 =
}
}
val MIGRATION_24_25 =
object : Migration(24, 25) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"ALTER TABLE `song_downloads` ADD COLUMN `downloadedAt` INTEGER NOT NULL DEFAULT 0",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -409,4 +418,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_21_22,
MIGRATION_22_23,
MIGRATION_23_24,
MIGRATION_24_25,
)
@@ -259,7 +259,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun downloadedSongs(
sourceId: Long,
@@ -271,7 +271,7 @@ interface QueueDao {
"SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
}
@@ -45,7 +45,7 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class,
SongDownload::class,
],
version = 24,
version = 25,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@@ -35,6 +35,7 @@ data class SongDownload(
@ColumnInfo(defaultValue = "0") val bytes: Long = 0,
@ColumnInfo(defaultValue = "0") val total: Long = 0,
val error: String? = null,
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) {
val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
@@ -555,6 +555,12 @@ class DownloadRepository(
status == DownloadStatus.Failed -> "Download failed"
else -> null
},
downloadedAt =
if (status == DownloadStatus.Completed && row.downloadedAt == 0L) {
System.currentTimeMillis()
} else {
row.downloadedAt
},
),
)
}
@@ -96,6 +96,10 @@ class LibraryRepository(
fun downloadedSongsPage(sourceId: Long): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) }
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
fun downloadedArtistsPage(sourceId: Long): Flow<PagingData<Artist>> = pager(60) { db.libraryDao().downloadedArtists(sourceId) }
fun songsByGenre(
sourceId: Long,
genre: String,
@@ -115,9 +119,8 @@ class LibraryRepository(
suspend fun downloadedSongOrdinal(
sourceId: Long,
title: String,
id: String,
): Long = db.libraryDao().downloadedSongOrdinal(sourceId, title, id)
): Long = db.libraryDao().downloadedSongOrdinal(sourceId, id)
fun albumsByDecade(
sourceId: Long,
@@ -1241,25 +1241,29 @@ class PlaybackController(
window: List<QueueWindowItem>,
position: Long,
) {
if (offline) {
loadWindow(position, autoplay = player?.playWhenReady == true, startPositionMs = player?.currentPositionMs ?: 0)
val player = player ?: return
downloads.awaitLoaded()
val ordered =
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
val index = ordered.indexOfFirst { it.position == position }
if (index < 0) {
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
return
}
val player = player ?: return
val index = window.indexOfFirst { it.position == position }
if (index < 0) return
windowPositions = LongArray(ordered.size) { ordered[it].position }
windowFiltered = offline
windowStart = ordered.first().position
windowEnd = ordered.last().position
lastPosition = position
updating = true
try {
repeat(player.currentIndex) { player.removeFirst() }
while (player.itemCount > 1) player.removeLast()
for (i in index - 1 downTo 0) player.addFirst(window[i].item.toQueueItem())
for (i in index + 1 until window.size) player.addLast(window[i].item.toQueueItem())
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
} finally {
updating = false
}
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = position
}
private fun currentPosition(): Long? {
@@ -10,10 +10,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
@@ -57,14 +54,7 @@ class HomeListViewModel(
.flatMapLatest { id ->
when {
request.downloaded == OfflineListKind.Albums -> {
libraryRepository.albums(
sourceId = id,
sort = AlbumSort.Added,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
libraryRepository.downloadedAlbumsPage(id)
}
request.decade != null -> {
@@ -107,14 +97,7 @@ class HomeListViewModel(
else -> {
if (request.downloaded == OfflineListKind.Artists) {
libraryRepository.artists(
sourceId = id,
sort = ArtistSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
libraryRepository.downloadedArtistsPage(id)
} else {
flowOf(PagingData.empty<Artist>())
}
@@ -10,10 +10,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.rediscoverCutoff
@@ -66,30 +63,10 @@ class HomeViewModel(
private val activeSource = libraryRepository.activeSourceId.filterNotNull()
val downloadedAlbums: Flow<PagingData<Album>> =
activeSource
.flatMapLatest { id ->
libraryRepository.albums(
sourceId = id,
sort = AlbumSort.Added,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
activeSource.flatMapLatest { libraryRepository.downloadedAlbumsPage(it) }.cachedIn(viewModelScope)
val downloadedArtists: Flow<PagingData<Artist>> =
activeSource
.flatMapLatest { id ->
libraryRepository.artists(
sourceId = id,
sort = ArtistSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
activeSource.flatMapLatest { libraryRepository.downloadedArtistsPage(it) }.cachedIn(viewModelScope)
val downloadedPlaylists: Flow<PagingData<Playlist>> =
activeSource
@@ -252,7 +229,7 @@ internal fun CoroutineScope.playDownloadedList(
song: Song,
) {
launch {
val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.title, song.id)
val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.id)
playbackController.playDownloaded(song.sourceId, ordinal)
}
}
@@ -217,6 +217,29 @@ class LibraryHomeDaoTest {
assertEquals(page.map { it.song.id }, dao.songsByGenreIds(sourceId, "Rock"))
}
@Test
fun downloadedSongsOrderByNewestDownload() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1")))
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One"),
song(sourceId, "s2", "Two"),
song(sourceId, "s3", "Three"),
),
)
val downloads = db.downloadDao()
downloads.upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed, downloadedAt = 100))
downloads.upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed, downloadedAt = 300))
downloads.upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed, downloadedAt = 200))
assertEquals(listOf("s3", "s2", "s1"), dao.downloadedSongs(sourceId).first().map { it.song.id })
assertEquals(listOf("s3", "s2", "s1"), dao.downloadedSongIds(sourceId))
assertEquals(2L, dao.downloadedSongOrdinal(sourceId, "s1"))
}
private suspend fun insertGenre(
sourceId: Long,
songId: String,