mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user