download a song to the device and play it from there

First block of #65: the state machine, the platform download engine, a
single-song download from the song context menu, the downloaded indicator in
song lists, and local-first playback.

- `song_downloads`, keyed `(sourceId, songId)` with CASCADE on the source, and
  the 16 -> 17 migration.
- `DownloadEngine` over `android.app.DownloadManager`, with a fake for tests.
  Files land in app-specific external storage at
  `.../Music/downloads/<sourceId>/<songId>`, plus a `.nomedia` so media scanners
  on pre-Android-10 devices leave them alone.
- `DownloadRepository` enqueues, mirrors progress by polling, cancels, deletes
  and reconciles on start and on source change: a queued row whose engine entry
  is gone is started again, a completed row whose file is gone becomes failed
  rather than being resurrected, and files with no row are swept.
- Playback prefers the downloaded file: the media item build takes the local URI
  when the download is complete and leaves it unclipped.
- Deleting a download reloads the player window at its position, so the item
  falls back to streaming instead of failing mid-read.
- Deleting a source cancels its downloads and removes their files.

Downloads are the system's own notifications and stay off metered networks for
now. Artwork, bulk downloads and the management screen are later blocks.
This commit is contained in:
austinried
2026-10-04 00:30:01 +00:00
parent 07fb07762c
commit 7ac79288b3
29 changed files with 2440 additions and 61 deletions
@@ -6,14 +6,19 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@@ -21,6 +26,7 @@ class AlbumDetailViewModel(
private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository,
private val playbackController: PlaybackController,
private val downloadRepository: DownloadRepository,
private val albumId: String,
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -31,11 +37,29 @@ class AlbumDetailViewModel(
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
val downloads: StateFlow<Map<String, SongDownload>> =
downloadRepository.states().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun download(song: Song) {
viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) }
}
fun cancelDownload(song: Song) {
viewModelScope.launch { downloadRepository.remove(song.sourceId, song.id) }
}
fun deleteDownload(song: Song) {
viewModelScope.launch {
downloadRepository.remove(song.sourceId, song.id)
playbackController.refreshMediaItems()
}
}
fun play(startIndex: Int) {
viewModelScope.launch {
playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong())