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 b21c297964
commit 2b104f1387
29 changed files with 2423 additions and 61 deletions
@@ -0,0 +1,34 @@
package com.subtracks.data.db
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import com.subtracks.data.model.SongDownload
import kotlinx.coroutines.flow.Flow
@Dao
interface DownloadDao {
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
fun downloads(sourceId: Long): Flow<List<SongDownload>>
@Query("SELECT * FROM song_downloads")
suspend fun all(): List<SongDownload>
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId")
suspend fun find(
sourceId: Long,
songId: String,
): SongDownload?
@Upsert
suspend fun upsert(download: SongDownload)
@Query("DELETE FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId")
suspend fun delete(
sourceId: Long,
songId: String,
)
@Query("DELETE FROM song_downloads WHERE sourceId = :sourceId")
suspend fun deleteSource(sourceId: Long)
}
@@ -369,6 +369,12 @@ interface LibraryDao {
songId: String,
): Flow<Song?>
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND id = :songId")
suspend fun songOnce(
sourceId: Long,
songId: String,
): Song?
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id")
fun albumsForArtist(
sourceId: Long,
@@ -243,6 +243,21 @@ private val PLAYLISTS_V14_INDICES =
"CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `\${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)",
)
val MIGRATION_17_18 =
object : Migration(17, 18) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `song_downloads` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
"`status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, " +
"`total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `queuedAt` INTEGER NOT NULL DEFAULT 0, " +
"PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)")
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)")
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -261,4 +276,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17,
MIGRATION_17_18,
)
@@ -7,12 +7,14 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadStatusConverter
import com.subtracks.data.model.PlaybackCursor
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKindConverter
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@@ -31,11 +33,12 @@ import com.subtracks.data.model.UpNextEntry
UpNextEntry::class,
PlaybackCursor::class,
ArtworkSeed::class,
SongDownload::class,
],
version = 17,
version = 18,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -44,4 +47,6 @@ abstract class SubtracksDatabase : RoomDatabase() {
abstract fun queueDao(): QueueDao
abstract fun artworkSeedDao(): ArtworkSeedDao
abstract fun downloadDao(): DownloadDao
}
@@ -0,0 +1,26 @@
package com.subtracks.data.download
data class EngineRequest(
val uri: String,
val path: String,
val title: String,
val mimeType: String? = null,
val allowMetered: Boolean = false,
)
enum class EngineStatus { Pending, Running, Completed, Failed }
data class EngineDownload(
val status: EngineStatus,
val bytes: Long = 0,
val total: Long = 0,
val error: String? = null,
)
interface DownloadEngine {
fun enqueue(request: EngineRequest): Long
fun download(id: Long): EngineDownload?
fun cancel(id: Long)
}
@@ -0,0 +1,66 @@
package com.subtracks.data.download
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.os.Environment
class SystemDownloadEngine(
private val context: Context,
) : DownloadEngine {
override fun enqueue(request: EngineRequest): Long {
val builder =
DownloadManager
.Request(Uri.parse(request.uri))
.setTitle(request.title)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.setAllowedOverMetered(request.allowMetered)
request.mimeType?.let(builder::setMimeType)
return manager().enqueue(builder)
}
override fun download(id: Long): EngineDownload? {
val query = DownloadManager.Query().setFilterById(id)
manager().query(query).use { cursor ->
if (!cursor.moveToFirst()) return null
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
val bytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
val total = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
val reason =
cursor
.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
.takeIf { status == DownloadManager.STATUS_FAILED }
val mapped =
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> EngineStatus.Completed
DownloadManager.STATUS_FAILED -> EngineStatus.Failed
DownloadManager.STATUS_RUNNING -> EngineStatus.Running
else -> EngineStatus.Pending
}
return EngineDownload(
status = mapped,
bytes = bytes.coerceAtLeast(0),
total = total.coerceAtLeast(0),
error = reason?.let(::failureMessage),
)
}
}
override fun cancel(id: Long) {
manager().remove(id)
}
private fun manager(): DownloadManager = context.getSystemService(DownloadManager::class.java)
private fun failureMessage(reason: Int): String =
when (reason) {
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable"
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download"
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times"
DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed"
else -> "Download failed"
}
}
@@ -0,0 +1,44 @@
package com.subtracks.data.model
import androidx.room3.ColumnInfo
import androidx.room3.ColumnTypeConverter
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
enum class DownloadStatus { Queued, Running, Completed, Failed }
@Entity(
tableName = "song_downloads",
primaryKeys = ["sourceId", "songId"],
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId"), Index("engineId")],
)
data class SongDownload(
val sourceId: Long,
val songId: String,
val status: DownloadStatus,
val engineId: Long? = null,
@ColumnInfo(defaultValue = "0") val bytes: Long = 0,
@ColumnInfo(defaultValue = "0") val total: Long = 0,
val error: String? = null,
@ColumnInfo(defaultValue = "0") val queuedAt: Long = 0,
) {
val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
}
class DownloadStatusConverter {
@ColumnTypeConverter
fun fromDownloadStatus(status: DownloadStatus): String = status.name
@ColumnTypeConverter
fun toDownloadStatus(value: String): DownloadStatus = DownloadStatus.entries.firstOrNull { it.name == value } ?: DownloadStatus.Failed
}
@@ -0,0 +1,261 @@
package com.subtracks.data.repo
import android.net.Uri
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.DownloadEngine
import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.SongDownload
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
class DownloadRepository(
private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository,
private val engine: DownloadEngine,
private val downloadsDir: File,
private val allowMetered: Boolean = false,
private val showMessage: (String) -> Unit = {},
dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val mutex = Mutex()
private val loaded = CompletableDeferred<Unit>()
private var pollJob: Job? = null
@Volatile
private var started = false
private val completedFiles = MutableStateFlow<Map<String, File>>(emptyMap())
fun start() {
if (started) return
started = true
downloadsDir.mkdirs()
File(downloadsDir, NO_MEDIA).createNewFile()
scope.launch {
sourceRepository.activeSourceId().distinctUntilChanged().collect { reconcile() }
}
scope.launch {
states().collect { rows ->
completedFiles.value =
rows
.filterValues { it.status == DownloadStatus.Completed }
.mapValues { (_, row) -> file(row.sourceId, row.songId) }
.filterValues { it.exists() }
loaded.complete(Unit)
}
}
}
fun close() {
scope.cancel()
}
fun states(): Flow<Map<String, SongDownload>> =
sourceRepository
.activeSourceId()
.flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) }
.map { rows -> rows.associateBy { it.songId } }
suspend fun awaitLoaded() {
if (started) loaded.await()
}
fun localUri(songId: String): String? = completedFiles.value[songId]?.takeIf { it.exists() }?.let { Uri.fromFile(it).toString() }
suspend fun download(
sourceId: Long,
songId: String,
) = mutex.withLock {
if (sourceRepository.activeSourceIdOnce() != sourceId) {
showMessage("Can't download from a source that isn't active")
return@withLock
}
val existing = db.downloadDao().find(sourceId, songId)
if (existing != null && existing.status != DownloadStatus.Failed) return@withLock
existing?.engineId?.let(engine::cancel)
val url = sourceRepository.downloadUri(songId)
if (url == null) {
showMessage("Can't download: the server address is unavailable")
return@withLock
}
enqueue(sourceId, songId, url)
}
suspend fun remove(
sourceId: Long,
songId: String,
) = mutex.withLock {
val row = db.downloadDao().find(sourceId, songId) ?: return@withLock
row.engineId?.let(engine::cancel)
file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId)
}
suspend fun removeSource(sourceId: Long) =
mutex.withLock {
db
.downloadDao()
.all()
.filter { it.sourceId == sourceId }
.forEach { row -> row.engineId?.let(engine::cancel) }
db.downloadDao().deleteSource(sourceId)
dir(sourceId).deleteRecursively()
}
suspend fun reconcile() =
mutex.withLock {
db.downloadDao().all().forEach { row ->
val state = row.engineId?.let(engine::download)
if (state == null) recover(row) else apply(row, state)
}
sweep(db.downloadDao().all())
ensurePolling()
}
private suspend fun enqueue(
sourceId: Long,
songId: String,
url: String,
) {
dir(sourceId).mkdirs()
file(sourceId, songId).delete()
val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId
val engineId =
engine.enqueue(
EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title, allowMetered = allowMetered),
)
db.downloadDao().upsert(
SongDownload(
sourceId = sourceId,
songId = songId,
status = DownloadStatus.Queued,
engineId = engineId,
queuedAt = System.currentTimeMillis(),
),
)
ensurePolling()
}
private suspend fun recover(row: SongDownload) {
when {
row.status == DownloadStatus.Completed && file(row.sourceId, row.songId).exists() -> {
Unit
}
row.status == DownloadStatus.Completed -> {
markFailed(row, "The downloaded file is missing")
}
sourceRepository.activeSourceIdOnce() != row.sourceId -> {
Unit
}
else -> {
val url = sourceRepository.downloadUri(row.songId)
when (url) {
null -> markFailed(row, "Can't download: the server address is unavailable")
else -> enqueue(row.sourceId, row.songId, url)
}
}
}
}
private suspend fun apply(
row: SongDownload,
state: EngineDownload,
) {
val status =
when (state.status) {
EngineStatus.Pending -> DownloadStatus.Queued
EngineStatus.Running -> DownloadStatus.Running
EngineStatus.Completed -> if (file(row.sourceId, row.songId).exists()) DownloadStatus.Completed else DownloadStatus.Failed
EngineStatus.Failed -> DownloadStatus.Failed
}
db.downloadDao().upsert(
row.copy(
status = status,
bytes = state.bytes,
total = state.total,
error = state.error ?: if (status == DownloadStatus.Failed) "Download failed" else null,
),
)
}
private suspend fun markFailed(
row: SongDownload,
message: String,
) {
row.engineId?.let(engine::cancel)
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = message))
}
private fun ensurePolling() {
if (pollJob?.isActive == true) return
pollJob =
scope.launch {
while (true) {
delay(POLL_MS)
mutex.withLock {
val active = db.downloadDao().all().filter { it.status.isActive }
if (active.isEmpty()) return@launch
active.forEach { row ->
val state = row.engineId?.let(engine::download)
if (state == null) recover(row) else apply(row, state)
}
}
}
}
}
private fun sweep(rows: List<SongDownload>) {
val known = rows.map { it.sourceId to it.songId }.toHashSet()
// Artwork (a later block) lives in subdirectories and is swept separately.
downloadsDir.listFiles().orEmpty().filter { it.isDirectory }.forEach { sourceDir ->
val sourceId = sourceDir.name.toLongOrNull() ?: return@forEach
sourceDir.listFiles().orEmpty().filter { it.isFile }.forEach { file ->
if ((sourceId to file.name) !in known) file.delete()
}
}
}
private fun dir(sourceId: Long): File = downloadsDir.resolve(sourceId.toString())
private fun file(
sourceId: Long,
songId: String,
): File = dir(sourceId).resolve(songId)
private fun enginePath(
sourceId: Long,
songId: String,
): String = "$ENGINE_DIR/$sourceId/$songId"
private val DownloadStatus.isActive: Boolean
get() = this == DownloadStatus.Queued || this == DownloadStatus.Running
private companion object {
const val ENGINE_DIR = "downloads"
const val NO_MEDIA = ".nomedia"
const val POLL_MS = 1_000L
}
}
@@ -94,6 +94,8 @@ class SourceRepository(
return uri + streamLengthSuffix(length)
}
fun downloadUri(songId: String): String? = active?.downloadUri(songId)?.toString()
suspend fun addSource(
name: String,
address: String,
@@ -1,14 +1,17 @@
package com.subtracks.di
import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.subtracks.data.db.createAndroidDatabase
import com.subtracks.data.download.SystemDownloadEngine
import com.subtracks.data.net.networkMode
import com.subtracks.data.prefs.createUserPreferences
import com.subtracks.data.repo.ArtworkSeedRepository
import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
@@ -27,6 +30,7 @@ import com.subtracks.ui.settings.SettingsViewModel
import okhttp3.OkHttpClient
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import java.io.File
fun appModule(
context: Context,
@@ -44,14 +48,24 @@ fun appModule(
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get()) }
single { QueueRepository(get()) }
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
single { PlaybackController(get(), get(), get(), showMessage = toast) }
single { SystemDownloadEngine(context.applicationContext) }
single {
DownloadRepository(
db = get(),
sourceRepository = get(),
engine = get(),
downloadsDir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir, "downloads"),
showMessage = toast,
).also { it.start() }
}
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { QueueViewModel(get(), get()) }
}
@@ -9,11 +9,13 @@ import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
class Media3PlayerHandle(
private val controller: MediaController,
private val sourceRepository: SourceRepository,
private val downloads: DownloadRepository,
) : PlayerHandle {
override val itemCount: Int get() = controller.mediaItemCount
@@ -127,37 +129,13 @@ class Media3PlayerHandle(
}
private fun toMediaItem(item: QueueItem): MediaItem {
val metadata =
MediaMetadata
.Builder()
.setTitle(item.title)
.setArtist(item.artist)
.setAlbumTitle(item.album)
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
.setDurationMs(item.durationMs)
.setIsBrowsable(false)
.setIsPlayable(true)
.setExtras(
Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId)
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
},
).build()
val quality = sourceRepository.quality.value
val builder =
MediaItem
.Builder()
.setMediaId(item.id)
.setUri(sourceRepository.streamUri(item.id, item.durationMs, quality))
.setMediaMetadata(metadata)
if (quality.transcodes) {
item.durationMs?.takeIf { it > 0 }?.let { duration ->
builder.setClippingConfiguration(
ClippingConfiguration.Builder().setEndPositionMs(duration).build(),
)
}
}
return builder.build()
return queueMediaItem(
item = item,
local = downloads.localUri(item.id),
stream = sourceRepository.streamUri(item.id, item.durationMs, quality),
transcode = quality.transcodes,
)
}
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
@@ -178,11 +156,50 @@ class Media3PlayerHandle(
private companion object {
const val TAG = "SubtracksPlayback"
const val EXTRA_COVER_ART_ID = "coverArtId"
const val EXTRA_DURATION_MS = "durationMs"
}
}
private const val EXTRA_COVER_ART_ID = "coverArtId"
private const val EXTRA_DURATION_MS = "durationMs"
internal fun queueMediaItem(
item: QueueItem,
local: String?,
stream: String?,
transcode: Boolean,
): MediaItem {
val metadata =
MediaMetadata
.Builder()
.setTitle(item.title)
.setArtist(item.artist)
.setAlbumTitle(item.album)
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
.setDurationMs(item.durationMs)
.setIsBrowsable(false)
.setIsPlayable(true)
.setExtras(
Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId)
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
},
).build()
val builder =
MediaItem
.Builder()
.setMediaId(item.id)
.setUri(local ?: stream)
.setMediaMetadata(metadata)
if (local == null && transcode) {
item.durationMs?.takeIf { it > 0 }?.let { duration ->
builder.setClippingConfiguration(
ClippingConfiguration.Builder().setEndPositionMs(duration).build(),
)
}
}
return builder.build()
}
internal fun playbackErrorMessage(error: PlaybackException): String =
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
@@ -3,6 +3,7 @@ package com.subtracks.playback
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueSnapshot
import com.subtracks.data.repo.QueueWindowItem
@@ -106,6 +107,7 @@ class PlaybackController(
private val sourceRepository: SourceRepository,
private val queueRepository: QueueRepository,
private val connection: PlayerConnection,
private val downloads: DownloadRepository,
private val showMessage: (String) -> Unit = {},
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
) {
@@ -928,6 +930,7 @@ class PlaybackController(
) {
val player = player ?: return
val snapshot = snapshot ?: return
downloads.awaitLoaded()
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
val startIndex = window.indexOfFirst { it.position == position }
if (startIndex < 0) return
@@ -946,19 +949,29 @@ class PlaybackController(
private suspend fun reloadQuality() =
startLock.withLock {
val player = player ?: return@withLock
val snapshot = snapshot ?: return@withLock
if (snapshot.size == 0L) return@withLock
val sourceId = queueSourceId
if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock
if (player.isEnded) return@withLock
val index = player.currentIndex
val position = (windowStart + index).coerceIn(0, snapshot.size - 1)
val positionMs = player.currentPositionMs
if (player.currentIndex != index) return@withLock
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
reloadWindowLocked()
}
fun refreshMediaItems() {
scope.launch {
startLock.withLock { reloadWindowLocked() }
}
}
private suspend fun reloadWindowLocked() {
val player = player ?: return
val snapshot = snapshot ?: return
if (snapshot.size == 0L) return
if (player.isEnded) return
val index = player.currentIndex
val position = (windowStart + index).coerceIn(0, snapshot.size - 1)
val positionMs = player.currentPositionMs
if (player.currentIndex != index) return
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
}
private fun jumpTo(target: Long) {
scope.launch {
startLock.withLock {
@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Context
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import java.util.concurrent.Executor
@@ -17,6 +18,7 @@ interface PlayerConnection {
class MediaSessionConnection(
private val context: Context,
private val sourceRepository: SourceRepository,
private val downloads: DownloadRepository,
) : PlayerConnection {
override fun connect(
onConnected: (PlayerHandle) -> Unit,
@@ -34,7 +36,7 @@ class MediaSessionConnection(
future.addListener(
{
val controller = runCatching { future.get() }.getOrNull()
if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository))
if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository, downloads))
},
Executor { it.run() },
)
@@ -14,6 +14,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Cancel
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle
@@ -43,7 +46,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType
import kotlinx.coroutines.launch
import com.subtracks.data.model.Album as AlbumModel
@@ -59,6 +64,7 @@ sealed interface MenuTarget {
data class Song(
val song: SongModel,
override val coverArt: CoverArtRef? = null,
val download: SongDownload? = null,
) : MenuTarget {
override val title: String get() = song.title
@@ -115,6 +121,9 @@ class ItemActions(
val shufflePlaylist: (PlaylistModel) -> Unit = {},
val playNext: ((QueueRef) -> Unit)? = null,
val addToQueue: ((QueueRef) -> Unit)? = null,
val download: ((SongModel) -> Unit)? = null,
val cancelDownload: ((SongModel) -> Unit)? = null,
val deleteDownload: ((SongModel) -> Unit)? = null,
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null,
@@ -184,6 +193,7 @@ fun ItemContextMenu(
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
}
queueItems()
DownloadItem(target.download, target.song, actions, ::dismiss)
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
@@ -261,6 +271,40 @@ private fun MenuHeader(target: MenuTarget) {
}
}
@Composable
private fun DownloadItem(
download: SongDownload?,
song: SongModel,
actions: ItemActions,
dismiss: (() -> Unit) -> Unit,
) {
when (download?.status) {
DownloadStatus.Completed -> {
actions.deleteDownload?.let { delete ->
MenuItem(Icons.Rounded.Delete, "Delete download") { dismiss { delete(song) } }
}
}
DownloadStatus.Queued, DownloadStatus.Running -> {
actions.cancelDownload?.let { cancel ->
MenuItem(Icons.Rounded.Cancel, "Cancel download") { dismiss { cancel(song) } }
}
}
DownloadStatus.Failed -> {
actions.download?.let { start ->
MenuItem(Icons.Rounded.Download, "Retry download") { dismiss { start(song) } }
}
}
null -> {
actions.download?.let { start ->
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(song) } }
}
}
}
}
@Composable
private fun StarItem(
current: Long?,
@@ -25,6 +25,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
@@ -55,6 +56,7 @@ fun AlbumDetailRoute(
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
@@ -65,6 +67,9 @@ fun AlbumDetailRoute(
shuffleAlbum = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
download = viewModel::download,
cancelDownload = viewModel::cancelDownload,
deleteDownload = viewModel::deleteDownload,
setStar = setStar,
viewArtist = onViewArtist,
)
@@ -74,6 +79,7 @@ fun AlbumDetailRoute(
discs = discs,
coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads,
onBack = onBack,
onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) },
@@ -96,6 +102,7 @@ fun AlbumDetailScreen(
discs: List<Disc> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef?,
artwork: ArtworkColors?,
downloads: Map<String, SongDownload> = emptyMap(),
onBack: () -> Unit,
onSongClick: (Int) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {},
@@ -165,10 +172,13 @@ fun AlbumDetailScreen(
song = song,
isPlaying = song.id == playingSongId,
trackNumber = song.track,
download = downloads[song.id],
modifier =
rowModifier.combinedClickable(
onClick = { onSongClick(index) },
onLongClick = { onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true))) },
onLongClick = {
onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id]))
},
),
)
}
@@ -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())
@@ -14,6 +14,7 @@ import androidx.paging.compose.itemKey
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongListItem
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
@@ -43,6 +44,7 @@ fun PlaylistDetailRoute(
playbackController: PlaybackController = koinInject(),
) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val actions =
@@ -51,6 +53,9 @@ fun PlaylistDetailRoute(
shufflePlaylist = { viewModel.shuffle() },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
download = viewModel::download,
cancelDownload = viewModel::cancelDownload,
deleteDownload = viewModel::deleteDownload,
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -60,6 +65,7 @@ fun PlaylistDetailRoute(
songs = viewModel.songs.collectAsLazyPagingItems(),
coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads,
onBack = onBack,
onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) },
@@ -76,6 +82,7 @@ fun PlaylistDetailScreen(
songs: LazyPagingItems<SongListItem>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artwork: ArtworkColors?,
downloads: Map<String, SongDownload> = emptyMap(),
onBack: () -> Unit,
onSongClick: (Int) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {},
@@ -114,10 +121,13 @@ fun PlaylistDetailScreen(
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
download = downloads[item.song.id],
modifier =
rowModifier.combinedClickable(
onClick = { onSongClick(index) },
onLongClick = { onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
onLongClick = {
onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true), downloads[item.song.id]))
},
),
)
}
@@ -6,15 +6,21 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongListItem
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)
@@ -22,6 +28,7 @@ class PlaylistDetailViewModel(
private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository,
private val playbackController: PlaybackController,
private val downloadRepository: DownloadRepository,
private val playlistId: String,
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -33,6 +40,24 @@ class PlaylistDetailViewModel(
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
.cachedIn(viewModelScope)
val downloads: StateFlow<Map<String, SongDownload>> =
downloadRepository.states().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
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 coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
@@ -22,7 +24,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.StarredBadge
@@ -33,6 +37,7 @@ fun SongRow(
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
isPlaying: Boolean = false,
trackNumber: Long? = null,
download: SongDownload? = null,
trailingContent: (@Composable () -> Unit)? = null,
modifier: Modifier = Modifier,
) {
@@ -82,6 +87,7 @@ fun SongRow(
modifier = Modifier.weight(1f, fill = false),
)
StarredBadge(song.starred != null)
DownloadBadge(download)
}
},
supportingContent = {
@@ -96,6 +102,33 @@ fun SongRow(
)
}
@Composable
private fun DownloadBadge(download: SongDownload?) {
when (download?.status) {
DownloadStatus.Completed -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = "Downloaded",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
DownloadStatus.Queued, DownloadStatus.Running -> {
val progress = download.progress
if (progress == null) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
} else {
CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
}
}
else -> {
Unit
}
}
}
@Composable
private fun TrackNumber(
track: Long,
@@ -46,6 +46,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -66,6 +67,7 @@ import coil3.request.ImageRequest
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
@@ -80,6 +82,7 @@ import com.subtracks.ui.theme.ArtworkSeedCache
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
const val NOW_PLAYING_COVER_TAG = "now-playing-cover"
@@ -95,11 +98,15 @@ fun NowPlayingRoute(
modifier: Modifier = Modifier,
controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(),
downloadRepository: DownloadRepository = koinInject(),
) {
val state by controller.state.collectAsStateWithLifecycle()
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
val sourceId = state.context?.sourceId
val songId = state.item?.id
val downloads by downloadRepository.states().collectAsStateWithLifecycle(initialValue = emptyMap())
val download = songId?.let { downloads[it] }
val scope = rememberCoroutineScope()
val song by
produceState<Song?>(null, sourceId, songId) {
if (sourceId != null && songId != null) {
@@ -109,6 +116,14 @@ fun NowPlayingRoute(
val context = LocalPlatformContext.current
val actions =
ItemActions(
download = { track -> scope.launch { downloadRepository.download(track.sourceId, track.id) } },
cancelDownload = { track -> scope.launch { downloadRepository.remove(track.sourceId, track.id) } },
deleteDownload = { track ->
scope.launch {
downloadRepository.remove(track.sourceId, track.id)
controller.refreshMediaItems()
}
},
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -142,7 +157,7 @@ fun NowPlayingRoute(
onPrevious = controller::previous,
onShuffle = controller::toggleShuffle,
onRepeat = controller::cycleRepeat,
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail), actions) } },
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
onSeek = controller::seekTo,
@@ -47,7 +47,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongListItem
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QUEUE_CHUNK
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueWindowItem
@@ -274,8 +276,10 @@ fun QueueRoute(
viewModel: QueueViewModel = koinViewModel(),
controller: PlaybackController = koinInject(),
sourceRepository: SourceRepository = koinInject(),
downloadRepository: DownloadRepository = koinInject(),
) {
val playback by controller.state.collectAsStateWithLifecycle()
val downloads by downloadRepository.states().collectAsStateWithLifecycle(initialValue = emptyMap())
val queueContext = playback.context
// The item's album keeps the header stable while the async name lookup runs, so it does not
// flash the fallback when tapping between context and manually queued tracks.
@@ -295,6 +299,7 @@ fun QueueRoute(
currentSongId = playback.item?.id,
shuffle = playback.shuffle,
contextTitle = sourceTitle.value,
downloads = downloads,
onClearUpNext = controller::clearUpNext,
coverArt = sourceRepository::coverArt,
onBack = onBack,
@@ -330,6 +335,7 @@ fun QueueScreen(
generation: Int = 0,
shuffle: Boolean = false,
contextTitle: String? = null,
downloads: Map<String, SongDownload> = emptyMap(),
onClearUpNext: () -> Unit = {},
) {
val listState = rememberLazyListState()
@@ -446,6 +452,7 @@ fun QueueScreen(
isPlaying = row.isPlaying(currentPosition, currentSongId),
floating = isDragging,
coverArt = coverArt,
download = downloads[row.song.song.id],
dragHandle =
if (!enabled) {
null
@@ -546,6 +553,7 @@ private fun QueueRowItem(
isPlaying: Boolean,
floating: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?,
download: SongDownload?,
dragHandle: Modifier?,
onClick: () -> Unit,
onRemove: () -> Unit,
@@ -554,6 +562,7 @@ private fun QueueRowItem(
song = row.song.song,
coverArtId = row.song.coverArt,
coverArt = coverArt,
download = download,
isPlaying = isPlaying,
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -14,6 +15,7 @@ import kotlinx.coroutines.launch
class SettingsViewModel(
private val sourceRepository: SourceRepository,
private val downloadRepository: DownloadRepository,
private val userPreferences: UserPreferences,
) : ViewModel() {
val sources: StateFlow<List<Source>> =
@@ -37,7 +39,10 @@ class SettingsViewModel(
}
fun deleteSource(id: Long) {
viewModelScope.launch { sourceRepository.deleteSource(id) }
viewModelScope.launch {
downloadRepository.removeSource(id)
sourceRepository.deleteSource(id)
}
}
fun setWifiQuality(quality: StreamQuality) {
@@ -0,0 +1,45 @@
package com.subtracks.data.download
class FakeDownloadEngine : DownloadEngine {
private val downloads = mutableMapOf<Long, EngineDownload>()
private var nextId = 1L
val requests = mutableListOf<Pair<Long, EngineRequest>>()
val cancelled = mutableListOf<Long>()
override fun enqueue(request: EngineRequest): Long {
val id = nextId++
requests += id to request
downloads[id] = EngineDownload(EngineStatus.Pending)
return id
}
override fun download(id: Long): EngineDownload? = downloads[id]
override fun cancel(id: Long) {
cancelled += id
}
fun running(
id: Long,
bytes: Long,
total: Long,
) {
downloads[id] = EngineDownload(EngineStatus.Running, bytes, total)
}
fun complete(
id: Long,
total: Long,
) {
downloads[id] = EngineDownload(EngineStatus.Completed, total, total)
}
fun fail(id: Long) {
downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed")
}
fun forget(id: Long) {
downloads.remove(id)
}
}
@@ -0,0 +1,268 @@
package com.subtracks.data.repo
import android.content.Context
import android.net.Uri
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.data.db.SubtracksDatabase
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
@RunWith(AndroidJUnit4::class)
class DownloadRepositoryTest {
private lateinit var db: SubtracksDatabase
private lateinit var sources: SourceRepository
private lateinit var engine: FakeDownloadEngine
private lateinit var repository: DownloadRepository
private lateinit var dir: File
private val messages = CopyOnWriteArrayList<String>()
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
repository = DownloadRepository(db, sources, engine, dir, showMessage = { messages += it })
}
@After
fun tearDown() {
repository.close()
sources.close()
db.close()
dir.deleteRecursively()
}
@Test
fun startingADownloadAsksTheEngineForTheOriginalAndRecordsIt() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
val request = engine.requests.single().second
assertTrue("expected the download endpoint, was ${request.uri}", request.uri.contains("/rest/download"))
assertEquals("downloads/1/s1", request.path)
assertEquals("Song 1", request.title)
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.running(id, bytes = 40, total = 100)
await { row(1, "s1")?.status == DownloadStatus.Running }
assertEquals(40L, row(1, "s1")?.bytes)
engine.complete(id, total = 100)
writeFile(1, "s1", "audio")
runBlocking { repository.reconcile() }
await { repository.localUri("s1") != null }
assertEquals(DownloadStatus.Completed, row(1, "s1")?.status)
assertEquals(Uri.fromFile(file(1, "s1")).toString(), repository.localUri("s1"))
}
@Test
fun aPartialFileIsNotTreatedAsPlayableWhileTheDownloadRuns() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.running(id, bytes = 40, total = 100)
writeFile(1, "s1", "half")
await { row(1, "s1")?.status == DownloadStatus.Running }
assertNull(repository.localUri("s1"))
}
@Test
fun aCompletedRowWhoseFileIsGoneIsFailedRatherThanLeftCompleted() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = null))
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
}
@Test
fun aCompletedEngineStateWithoutAFileIsFailed() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.complete(id, total = 100)
runBlocking { repository.reconcile() }
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
}
@Test
fun aQueuedRowWhoseEngineEntryIsGoneIsStartedAgain() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Queued, engineId = 7))
runBlocking { repository.reconcile() }
val request = engine.requests.single()
assertNotEquals(7L, request.first)
assertEquals("downloads/1/s1", request.second.path)
}
@Test
fun removingADownloadCancelsItAndDeletesTheFile() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
runBlocking { repository.remove(1, "s1") }
assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists())
assertNull(row(1, "s1"))
}
@Test
fun aFailedDownloadCanBeStartedAgain() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = "boom"))
runBlocking { repository.download(1, "s1") }
assertEquals(1, engine.requests.size)
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
fun downloadingFromASourceThatIsNotActiveIsRefused() =
runTest {
seedLibrary()
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 0))
db.sourcesDao().upsertSubsonicSource(SubsonicSource(2, "user", "pass"))
runBlocking { repository.download(2, "s1") }
assertTrue(engine.requests.isEmpty())
assertEquals(listOf("Can't download from a source that isn't active"), messages.toList())
}
@Test
fun removingASourceCancelsItsDownloadsAndDeletesItsFiles() =
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
runBlocking { repository.removeSource(1) }
assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists())
assertTrue(allRows().isEmpty())
}
@Test
fun reconcileDeletesFilesThatBelongToNoDownload() =
runTest {
seedLibrary()
writeFile(1, "orphan", "audio")
runBlocking { repository.reconcile() }
assertFalse(file(1, "orphan").exists())
}
private fun seedLibrary() {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song 1",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
),
),
)
}
await { sources.downloadUri("s1") != null }
}
private fun file(
sourceId: Long,
songId: String,
): File = File(File(dir, sourceId.toString()), songId)
private fun row(
sourceId: Long,
songId: String,
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun writeFile(
sourceId: Long,
songId: String,
contents: String,
) {
val file = file(sourceId, songId)
file.parentFile?.mkdirs()
file.writeText(contents)
}
private fun await(predicate: () -> Boolean) {
val deadline = System.nanoTime() + 5_000_000_000L
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
assertTrue("Timed out waiting for the expected download state", predicate())
}
}
@@ -0,0 +1,57 @@
package com.subtracks.playback
import androidx.media3.common.C
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MediaItemTest {
private val song =
QueueItem(
id = "s1",
title = "Song",
artist = "Artist",
album = "Album",
coverArtId = "al1",
durationMs = 100_000L,
)
@Test
fun aDownloadedFileIsPreferredOverTheStream() {
val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = false)
assertEquals("file:///downloads/1/s1", item.localConfiguration?.uri.toString())
}
@Test
fun theStreamIsUsedWhenThereIsNoDownload() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals("http://server/stream?s=s1", item.localConfiguration?.uri.toString())
}
@Test
fun aDownloadedFileIsNotClippedEvenForATranscodingQuality() {
val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = true)
assertEquals(C.TIME_END_OF_SOURCE, item.clippingConfiguration?.endPositionMs)
}
@Test
fun aTranscodedStreamIsClippedToItsDeclaredDuration() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = true)
assertEquals(100_000L, item.clippingConfiguration?.endPositionMs)
}
@Test
fun theItemKeepsItsIdTitleAndCoverArt() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals("s1", item.mediaId)
assertEquals("Song", item.mediaMetadata.title.toString())
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
}
}
@@ -7,6 +7,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
@@ -16,6 +17,7 @@ import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.Dispatchers
@@ -34,6 +36,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
@@ -46,6 +49,7 @@ class PlaybackControllerTest {
private lateinit var networkMode: MutableStateFlow<NetworkMode>
private lateinit var sources: SourceRepository
private lateinit var queues: QueueRepository
private lateinit var downloads: DownloadRepository
private lateinit var handle: FakePlayerHandle
private lateinit var controller: PlaybackController
private val messages = CopyOnWriteArrayList<String>()
@@ -62,15 +66,24 @@ class PlaybackControllerTest {
networkMode = MutableStateFlow(NetworkMode.Wifi)
sources = SourceRepository(db, OkHttpClient(), prefs, networkMode = networkMode)
queues = QueueRepository(db)
downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
handle = FakePlayerHandle()
controller =
PlaybackController(sources, queues, FakePlayerConnection(handle), showMessage = { messages += it }, dispatcher = dispatcher)
PlaybackController(
sources,
queues,
FakePlayerConnection(handle),
downloads,
showMessage = { messages += it },
dispatcher = dispatcher,
)
}
@After
fun tearDown() {
controller.close()
sources.close()
downloads.close()
db.close()
dispatcher.close()
}
@@ -616,7 +629,7 @@ class PlaybackControllerTest {
runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher)
restored.connect()
await {
restored.state.value.item
@@ -724,7 +737,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -771,7 +784,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -814,7 +827,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -857,7 +870,7 @@ class PlaybackControllerTest {
block: Runnable,
) = block.run()
}
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
eager.playAlbum(1, "al1", 0)
await {
@@ -2241,7 +2254,7 @@ class PlaybackControllerTest {
runBlocking { queues.setPosition(12_000L) }
val restoredHandle = FakePlayerHandle()
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher)
restored.connect()
await {
restored.state.value.item
@@ -2277,6 +2290,21 @@ class PlaybackControllerTest {
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
}
@Test
fun refreshingMediaItemsRebuildsTheWindowAtTheSamePosition() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { handle.operations.contains("play") }
handle.positionMs = 42_000L
val windows = handle.operations.count { it.startsWith("setWindow") }
controller.refreshMediaItems()
await { handle.operations.count { it.startsWith("setWindow") } > windows }
assertEquals("s1", handle.currentItem?.id)
assertEquals(42_000L, handle.positionMs)
}
@Test
fun positionTickerAdvancesPositionWithoutEmittingPlaybackState() =
runBlocking {
@@ -0,0 +1,72 @@
package com.subtracks.ui.library
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SongRowDownloadTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
genre = null,
)
@Test
fun aCompletedDownloadShowsTheDownloadedIndicator() {
render(download(DownloadStatus.Completed))
composeRule.onNodeWithContentDescription("Downloaded").assertIsDisplayed()
}
@Test
fun aRunningDownloadShowsNoDownloadedIndicator() {
render(download(DownloadStatus.Running))
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
}
@Test
fun aSongWithoutADownloadShowsNoIndicator() {
render(null)
composeRule.onNodeWithText("Song").assertIsDisplayed()
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
}
private fun download(status: DownloadStatus) =
SongDownload(sourceId = 1, songId = "s1", status = status, engineId = 7, bytes = 40, total = 100)
private fun render(download: SongDownload?) {
composeRule.setContent {
SubtracksTheme {
SongRow(song = song, download = download)
}
}
}
}
@@ -6,11 +6,13 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
@@ -30,6 +32,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@@ -53,7 +56,8 @@ class QueueViewModelTest {
.build()
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
queues = QueueRepository(db)
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher = dispatcher)
val downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher)
viewModel = QueueViewModel(queues, controller)
}