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 " + "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 " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + "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>> fun downloadedSongs(sourceId: Long): Flow<List<AlbumSongItem>>
@@ -717,7 +717,7 @@ interface LibraryDao {
"SELECT songs.id FROM songs " + "SELECT songs.id FROM songs " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + "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> 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 " + "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 " + "JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " + "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> 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( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_genres " + "SELECT songs.*, albums.coverArt AS coverArt FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " + "JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
@@ -768,13 +789,15 @@ interface LibraryDao {
@Query( @Query(
"SELECT COUNT(*) FROM song_downloads d " + "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' " + "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( suspend fun downloadedSongOrdinal(
sourceId: Long, sourceId: Long,
title: String,
id: String, id: String,
): Long ): 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. // 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,
@@ -409,4 +418,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_21_22, MIGRATION_21_22,
MIGRATION_22_23, MIGRATION_22_23,
MIGRATION_23_24, MIGRATION_23_24,
MIGRATION_24_25,
) )
@@ -259,7 +259,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "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 " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + "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( suspend fun downloadedSongs(
sourceId: Long, sourceId: Long,
@@ -271,7 +271,7 @@ interface QueueDao {
"SELECT songs.id FROM song_downloads d " + "SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " + "JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " + "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> suspend fun downloadedSongIds(sourceId: Long): List<String>
} }
@@ -45,7 +45,7 @@ import com.subtracks.data.model.UpNextEntry
ArtworkSeed::class, ArtworkSeed::class,
SongDownload::class, SongDownload::class,
], ],
version = 24, version = 25,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class) @ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
@@ -35,6 +35,7 @@ data class SongDownload(
@ColumnInfo(defaultValue = "0") val bytes: Long = 0, @ColumnInfo(defaultValue = "0") val bytes: Long = 0,
@ColumnInfo(defaultValue = "0") val total: Long = 0, @ColumnInfo(defaultValue = "0") val total: Long = 0,
val error: String? = null, val error: String? = null,
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) { ) {
val progress: Float? val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
@@ -555,6 +555,12 @@ class DownloadRepository(
status == DownloadStatus.Failed -> "Download failed" status == DownloadStatus.Failed -> "Download failed"
else -> null 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 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( fun songsByGenre(
sourceId: Long, sourceId: Long,
genre: String, genre: String,
@@ -115,9 +119,8 @@ class LibraryRepository(
suspend fun downloadedSongOrdinal( suspend fun downloadedSongOrdinal(
sourceId: Long, sourceId: Long,
title: String,
id: String, id: String,
): Long = db.libraryDao().downloadedSongOrdinal(sourceId, title, id) ): Long = db.libraryDao().downloadedSongOrdinal(sourceId, id)
fun albumsByDecade( fun albumsByDecade(
sourceId: Long, sourceId: Long,
@@ -1241,25 +1241,29 @@ class PlaybackController(
window: List<QueueWindowItem>, window: List<QueueWindowItem>,
position: Long, position: Long,
) { ) {
if (offline) { val player = player ?: return
loadWindow(position, autoplay = player?.playWhenReady == true, startPositionMs = player?.currentPositionMs ?: 0) 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 return
} }
val player = player ?: return windowPositions = LongArray(ordered.size) { ordered[it].position }
val index = window.indexOfFirst { it.position == position } windowFiltered = offline
if (index < 0) return windowStart = ordered.first().position
windowEnd = ordered.last().position
lastPosition = position
updating = true updating = true
try { try {
repeat(player.currentIndex) { player.removeFirst() } repeat(player.currentIndex) { player.removeFirst() }
while (player.itemCount > 1) player.removeLast() while (player.itemCount > 1) player.removeLast()
for (i in index - 1 downTo 0) player.addFirst(window[i].item.toQueueItem()) for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
for (i in index + 1 until window.size) player.addLast(window[i].item.toQueueItem()) for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
} finally { } finally {
updating = false updating = false
} }
windowStart = window.first().position
windowEnd = window.last().position
lastPosition = position
} }
private fun currentPosition(): Long? { 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.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song 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.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
@@ -57,14 +54,7 @@ class HomeListViewModel(
.flatMapLatest { id -> .flatMapLatest { id ->
when { when {
request.downloaded == OfflineListKind.Albums -> { request.downloaded == OfflineListKind.Albums -> {
libraryRepository.albums( libraryRepository.downloadedAlbumsPage(id)
sourceId = id,
sort = AlbumSort.Added,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
} }
request.decade != null -> { request.decade != null -> {
@@ -107,14 +97,7 @@ class HomeListViewModel(
else -> { else -> {
if (request.downloaded == OfflineListKind.Artists) { if (request.downloaded == OfflineListKind.Artists) {
libraryRepository.artists( libraryRepository.downloadedArtistsPage(id)
sourceId = id,
sort = ArtistSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
} else { } else {
flowOf(PagingData.empty<Artist>()) 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.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song 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.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.repo.rediscoverCutoff import com.subtracks.data.repo.rediscoverCutoff
@@ -66,30 +63,10 @@ class HomeViewModel(
private val activeSource = libraryRepository.activeSourceId.filterNotNull() private val activeSource = libraryRepository.activeSourceId.filterNotNull()
val downloadedAlbums: Flow<PagingData<Album>> = val downloadedAlbums: Flow<PagingData<Album>> =
activeSource activeSource.flatMapLatest { libraryRepository.downloadedAlbumsPage(it) }.cachedIn(viewModelScope)
.flatMapLatest { id ->
libraryRepository.albums(
sourceId = id,
sort = AlbumSort.Added,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
val downloadedArtists: Flow<PagingData<Artist>> = val downloadedArtists: Flow<PagingData<Artist>> =
activeSource activeSource.flatMapLatest { libraryRepository.downloadedArtistsPage(it) }.cachedIn(viewModelScope)
.flatMapLatest { id ->
libraryRepository.artists(
sourceId = id,
sort = ArtistSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
val downloadedPlaylists: Flow<PagingData<Playlist>> = val downloadedPlaylists: Flow<PagingData<Playlist>> =
activeSource activeSource
@@ -252,7 +229,7 @@ internal fun CoroutineScope.playDownloadedList(
song: Song, song: Song,
) { ) {
launch { launch {
val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.title, song.id) val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.id)
playbackController.playDownloaded(song.sourceId, ordinal) playbackController.playDownloaded(song.sourceId, ordinal)
} }
} }
@@ -217,6 +217,29 @@ class LibraryHomeDaoTest {
assertEquals(page.map { it.song.id }, dao.songsByGenreIds(sourceId, "Rock")) 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( private suspend fun insertGenre(
sourceId: Long, sourceId: Long,
songId: String, songId: String,